use crate::explorer::view::ViewQuery;
use crate::explorer::{column, view, walk};
use crate::http::server::{AppState, Mode};
use crate::paths;
use crate::render;
use crate::repo::RepoSet;
use crate::search;

use axum::{
    body::Body,
    extract::{Query, RawQuery, State},
    http::{HeaderMap, StatusCode, header},
    response::Response,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use tracing::warn;

#[derive(Serialize)]
struct TreeResponse {
    mode: &'static str,
    root: String,
    ready: bool,
    dirs: BTreeMap<String, walk::NavDir>,
}

pub(crate) async fn tree(
    State(s): State<AppState>,
    RawQuery(raw_query): RawQuery,
    Query(q): Query<ViewQuery>,
) -> Response {
    let view = view::from_query(&q, raw_query.as_deref(), &s.view_cfg, &s.filters);
    let matcher = view::matcher(&view, &s.filters);
    let tree = if view.use_ignore == s.use_ignore {
        s.cached_nav_tree(&view, matcher.as_ref())
    } else {
        Some(s.nav_tree(&view, matcher.as_ref()))
    };
    let root = s
        .target
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    let resp = TreeResponse {
        mode: if s.mode == Mode::Dir { "dir" } else { "file" },
        root,
        ready: tree.is_some(),
        dirs: tree.map(|tree| tree.dirs.clone()).unwrap_or_default(),
    };
    json_response(&resp, "api_tree")
}

#[derive(Default, Deserialize)]
pub(crate) struct SearchQuery {
    q: Option<String>,
    path: Option<String>,
    #[serde(flatten)]
    view: ViewQuery,
}

pub(crate) async fn search(
    State(s): State<AppState>,
    headers: HeaderMap,
    RawQuery(raw_query): RawQuery,
    Query(q): Query<SearchQuery>,
) -> Response {
    let query = match q.q.as_deref() {
        Some(q) if !q.is_empty() => q,
        _ => return bad_request(r#"{"error":"missing query"}"#),
    };

    let (search_root, path_prefix) = resolve_search_scope(&s.target, q.path.as_deref());
    let search_root = match search_root {
        Some(root) => root,
        None => return bad_request(r#"{"error":"invalid path"}"#),
    };

    let view = view::from_query(&q.view, raw_query.as_deref(), &s.view_cfg, &s.filters);
    let exclude_names = if view.opts.show_excludes {
        &[][..]
    } else {
        &s.exclude_names
    };
    let matcher = view::matcher(&view, &s.filters);
    let filter_exts = view::filter_exts(&view, &s.filter_exts);

    let resp = search::search(search::SearchOpts {
        query,
        session_root: &s.target,
        walk_root: &search_root,
        use_ignore: view.use_ignore,
        hidden: view.opts.show_hidden,
        exclude_names,
        filter_exts,
        group_filter: matcher.as_ref(),
        max_rows: s.search_max_rows,
    });

    if wants_html(&headers) {
        return search::view::content_fragment(&resp, &view, &s.view_cfg, path_prefix.as_deref())
            .unwrap_or_else(not_found);
    }

    json_response(&resp, "api_search")
}

#[derive(Default, Deserialize)]
pub(crate) struct PathSearchQuery {
    q: Option<String>,
    path: Option<String>,
    #[serde(flatten)]
    view: ViewQuery,
}

pub(crate) async fn path_search(
    State(s): State<AppState>,
    headers: HeaderMap,
    RawQuery(raw_query): RawQuery,
    Query(q): Query<PathSearchQuery>,
) -> Response {
    let query = match q.q.as_deref() {
        Some(q) if !q.is_empty() => q,
        _ => return bad_request(r#"{"error":"missing query"}"#),
    };

    let view = view::from_query(&q.view, raw_query.as_deref(), &s.view_cfg, &s.filters);
    let current_path = q.path.as_deref().unwrap_or("").trim_matches('/');
    let matcher = view::matcher(&view, &s.filters);
    let load_lines = view.sort == walk::Sort::Lines
        || column::required_meta(&view.columns).contains(column::MetaReq::LINES);
    let load_commit_meta = column::required_meta(&view.columns).contains(column::MetaReq::COMMIT);
    let rows = if view.use_ignore == s.use_ignore {
        let nav = s.nav.read().unwrap();
        if !nav.is_ready() {
            let resp = search::view::PathResponse::pending(s.search_max_rows);
            if wants_html(&headers) {
                return search::view::path_fragment(&resp, query, &view, &s.view_cfg)
                    .unwrap_or_else(not_found);
            }
            return json_response(&resp, "api_path_search");
        }
        search::path::nav(
            &nav,
            search::path::NavSpec {
                current_path,
                query,
                max_rows: s.search_max_rows,
                sort: view.sort,
                dir: view.sort_dir,
                opts: view.opts,
                matcher: matcher.as_ref(),
                load_lines,
                load_commit_meta,
            },
            |rows| load_path_search_commits(rows, Some(&s.target), Some(&s.repos)),
        )
    } else {
        let mut guard = s.alternate_nav.write().unwrap();
        if guard.is_none() {
            *guard = Some(walk::build_all(
                &s.target,
                view.use_ignore,
                &s.exclude_names,
                &s.filter_exts,
                s.show_excludes,
            ));
        }
        search::path::nav(
            guard.as_ref().unwrap(),
            search::path::NavSpec {
                current_path,
                query,
                max_rows: s.search_max_rows,
                sort: view.sort,
                dir: view.sort_dir,
                opts: view.opts,
                matcher: matcher.as_ref(),
                load_lines,
                load_commit_meta,
            },
            |rows| load_path_search_commits(rows, Some(&s.target), Some(&s.repos)),
        )
    };
    let resp = search::view::path_response(rows, &view.columns);

    if wants_html(&headers) {
        return search::view::path_fragment(&resp, query, &view, &s.view_cfg)
            .unwrap_or_else(not_found);
    }

    json_response(&resp, "api_path_search")
}

#[cfg(test)]
struct PathSearchSpec<'a> {
    tree: &'a walk::NavTree,
    current_path: &'a str,
    query: &'a str,
    max_rows: usize,
    sort: walk::Sort,
    dir: walk::SortDir,
    columns: &'a column::Set,
    target: Option<&'a Path>,
    repos: Option<&'a RepoSet>,
}

#[cfg(test)]
fn path_search_results(spec: PathSearchSpec<'_>) -> search::view::PathResponse {
    let rows = search::path::tree(
        search::path::TreeSpec {
            tree: spec.tree,
            current_path: spec.current_path,
            query: spec.query,
            max_rows: spec.max_rows,
            sort: spec.sort,
            dir: spec.dir,
            load_commit_meta: column::required_meta(spec.columns).contains(column::MetaReq::COMMIT),
        },
        |rows| load_path_search_commits(rows, spec.target, spec.repos),
    );
    search::view::path_response(rows, spec.columns)
}

fn load_path_search_commits(
    rows: &mut [search::path::Row],
    target: Option<&Path>,
    repos: Option<&RepoSet>,
) {
    let (Some(target), Some(repos)) = (target, repos) else {
        return;
    };
    let paths = rows
        .iter()
        .map(|row| path_search_abs(target, &row.href))
        .collect::<Vec<_>>();
    let commits = repos.commit_info(&paths);
    for (row, path) in rows.iter_mut().zip(paths) {
        if let Some(commit) = commits.get(&path) {
            row.commit_subject = Some(commit.subject.clone());
            row.commit_author = Some(commit.author.clone());
            row.commit_email = Some(commit.email.clone());
            row.commit_timestamp = Some(commit.timestamp);
        }
    }
}

fn path_search_abs(target: &Path, href: &str) -> PathBuf {
    target.join(href.trim_matches('/'))
}

#[derive(Deserialize)]
pub(crate) struct RenderQuery {
    path: Option<String>,
}

pub(crate) async fn render(State(s): State<AppState>, Query(q): Query<RenderQuery>) -> Response {
    let rel = q.path.as_deref().unwrap_or("").trim_matches('/');

    let (file_path, root) = if s.mode == Mode::File {
        let parent = s.target.parent().unwrap_or(&s.target).to_path_buf();
        let fp = if rel.is_empty() {
            s.target.clone()
        } else {
            parent.join(rel)
        };
        (fp, parent)
    } else {
        let fp = if rel.is_empty() {
            s.target.clone()
        } else {
            s.target.join(rel)
        };
        (fp, s.target.clone())
    };

    let md = match tokio::fs::read_to_string(&file_path).await {
        Ok(m) => m,
        Err(_) => return not_found(),
    };

    let rendered = render::render_at(
        &md,
        Some(render::RenderPath {
            root: &root,
            src: &file_path,
        }),
    );

    json_response(&rendered, "api_render")
}

fn json_response<T: Serialize>(value: &T, label: &str) -> Response {
    match serde_json::to_string(value) {
        Ok(json) => Response::builder()
            .status(StatusCode::OK)
            .header(header::CONTENT_TYPE, "application/json")
            .body(Body::from(json))
            .unwrap(),
        Err(e) => {
            warn!("{label} error: {}", e);
            not_found()
        }
    }
}

fn wants_html(headers: &HeaderMap) -> bool {
    headers
        .get("HX-Request")
        .and_then(|value| value.to_str().ok())
        .is_some_and(|value| value == "true")
        || headers
            .get(header::ACCEPT)
            .and_then(|value| value.to_str().ok())
            .is_some_and(|value| value.contains("text/html"))
}

fn resolve_search_scope(target: &Path, path: Option<&str>) -> (Option<PathBuf>, Option<String>) {
    let raw = path.unwrap_or("").trim_matches('/');
    if raw.is_empty() {
        return (Some(target.to_path_buf()), None);
    }
    let rel = match paths::safe_rel(raw) {
        Some(r) => r,
        None => return (None, None),
    };
    let scope = target.join(&rel);
    if !scope.is_dir() {
        return (None, None);
    }
    let canon_target = match target.canonicalize() {
        Ok(p) => p,
        Err(_) => return (None, None),
    };
    let canon_scope = match scope.canonicalize() {
        Ok(p) => p,
        Err(_) => return (None, None),
    };
    if !canon_scope.starts_with(&canon_target) {
        return (None, None);
    }
    (Some(scope), Some(rel.to_string_lossy().into_owned()))
}

fn bad_request(body: &'static str) -> Response {
    Response::builder()
        .status(StatusCode::BAD_REQUEST)
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(body))
        .unwrap()
}

fn not_found() -> Response {
    Response::builder()
        .status(StatusCode::NOT_FOUND)
        .header(header::CACHE_CONTROL, "no-store")
        .body(Body::from("404"))
        .unwrap()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::{TempDir, nav_entry};
    use axum::body::to_bytes;
    use serde::Serialize;
    use std::fs;

    async fn response_text(response: Response) -> String {
        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
        String::from_utf8(bytes.to_vec()).unwrap()
    }

    #[derive(Serialize)]
    struct TestJson {
        ok: bool,
        name: &'static str,
    }

    #[tokio::test]
    async fn json_response_sets_json_headers_and_body() {
        let response = json_response(
            &TestJson {
                ok: true,
                name: "api",
            },
            "test",
        );

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.headers().get(header::CONTENT_TYPE).unwrap(),
            "application/json"
        );
        assert_eq!(response_text(response).await, r#"{"ok":true,"name":"api"}"#);
    }

    #[tokio::test]
    async fn bad_request_returns_json_error() {
        let response = bad_request(r#"{"error":"missing query"}"#);

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response.headers().get(header::CONTENT_TYPE).unwrap(),
            "application/json"
        );
        assert_eq!(
            response_text(response).await,
            r#"{"error":"missing query"}"#
        );
    }

    #[tokio::test]
    async fn not_found_returns_no_store_response() {
        let response = not_found();

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
        assert_eq!(
            response.headers().get(header::CACHE_CONTROL).unwrap(),
            "no-store"
        );
        assert_eq!(response_text(response).await, "404");
    }

    #[test]
    fn wants_html_accepts_hx_or_html_headers() {
        let mut headers = HeaderMap::new();
        assert!(!wants_html(&headers));

        headers.insert("HX-Request", "true".parse().unwrap());
        assert!(wants_html(&headers));

        headers.insert("HX-Request", "false".parse().unwrap());
        headers.insert(header::ACCEPT, "application/json".parse().unwrap());
        assert!(!wants_html(&headers));

        headers.insert(
            header::ACCEPT,
            "text/html,application/xhtml+xml".parse().unwrap(),
        );
        assert!(wants_html(&headers));
    }

    #[test]
    fn path_search_uses_selected_sort() {
        let td = TempDir::new("ghrm-path-search");
        fs::write(td.path().join("newer.md"), "one\ntwo\nthree\n").unwrap();
        let mut dirs = BTreeMap::new();
        dirs.insert(
            String::new(),
            walk::NavDir {
                entries: vec![
                    walk::NavEntry {
                        href: "/src/".to_string(),
                        ..nav_entry("src", true, Some(3))
                    },
                    walk::NavEntry {
                        href: "/older.md".to_string(),
                        ..nav_entry("older.md", false, Some(1))
                    },
                    walk::NavEntry {
                        href: "/newer.md".to_string(),
                        size: Some(2048),
                        lines: Some(3),
                        ..nav_entry("newer.md", false, Some(9))
                    },
                ],
                readme: None,
            },
        );
        let tree = walk::NavTree { dirs };
        let resp = path_search_results(PathSearchSpec {
            tree: &tree,
            current_path: "",
            query: "m",
            max_rows: 10,
            sort: walk::Sort::Size,
            dir: walk::SortDir::Desc,
            columns: &column::Set::from_defaults(|def| def.key == "date" || def.key == "size"),
            target: None,
            repos: None,
        });
        let date_cell = resp.results[0]
            .cells
            .iter()
            .find(|cell| cell.key == "date")
            .unwrap();
        assert_eq!(date_cell.timestamp, Some(9));
        assert!(!date_cell.hidden);

        let size_cell = resp.results[0]
            .cells
            .iter()
            .find(|cell| cell.key == "size")
            .unwrap();
        assert_eq!(size_cell.text.as_deref(), Some("2.0 KB"));
        assert!(!size_cell.hidden);

        let line_cell = resp.results[0]
            .cells
            .iter()
            .find(|cell| cell.key == "lines")
            .unwrap();
        assert_eq!(line_cell.text.as_deref(), Some("3"));
        assert!(line_cell.hidden);

        let commit_cell = resp.results[0]
            .cells
            .iter()
            .find(|cell| cell.key == "commit")
            .unwrap();
        assert!(commit_cell.hidden);

        let names: Vec<_> = resp.results.into_iter().map(|row| row.display).collect();
        assert_eq!(names, vec!["newer.md", "older.md"]);
    }

    #[test]
    fn path_search_truncates_after_ranking() {
        let mut dirs = BTreeMap::new();
        dirs.insert(
            String::new(),
            walk::NavDir {
                entries: vec![
                    walk::NavEntry {
                        href: "/match-small.md".to_string(),
                        size: Some(1),
                        ..nav_entry("match-small.md", false, Some(1))
                    },
                    walk::NavEntry {
                        href: "/match-large.md".to_string(),
                        size: Some(3000),
                        ..nav_entry("match-large.md", false, Some(1))
                    },
                    walk::NavEntry {
                        href: "/match-mid.md".to_string(),
                        size: Some(2000),
                        ..nav_entry("match-mid.md", false, Some(1))
                    },
                ],
                readme: None,
            },
        );
        let tree = walk::NavTree { dirs };

        let resp = path_search_results(PathSearchSpec {
            tree: &tree,
            current_path: "",
            query: "match",
            max_rows: 2,
            sort: walk::Sort::Size,
            dir: walk::SortDir::Desc,
            columns: &column::Set::from_defaults(|_| false),
            target: None,
            repos: None,
        });

        assert!(resp.truncated);
        let names: Vec<_> = resp.results.into_iter().map(|row| row.display).collect();
        assert_eq!(names, vec!["match-large.md", "match-mid.md"]);
    }

    #[test]
    fn resolve_search_scope_rejects_parent_traversal() {
        let td = TempDir::new("ghrm-scope");
        fs::create_dir_all(td.path().join("sub")).unwrap();

        let (scope, _) = resolve_search_scope(td.path(), Some("../escape"));
        assert!(scope.is_none());

        let (scope, _) = resolve_search_scope(td.path(), Some("sub/../.."));
        assert!(scope.is_none());
    }

    #[test]
    fn resolve_search_scope_returns_prefix_for_child() {
        let td = TempDir::new("ghrm-scope");
        fs::create_dir_all(td.path().join("plans/brege")).unwrap();

        let (scope, prefix) = resolve_search_scope(td.path(), Some("plans/brege"));
        assert!(scope.is_some());
        assert_eq!(scope.unwrap(), td.path().join("plans/brege"));
        assert_eq!(prefix.as_deref(), Some("plans/brege"));
    }

    #[cfg(unix)]
    #[test]
    fn resolve_search_scope_rejects_symlink_escape() {
        use std::os::unix::fs::symlink;

        let td = TempDir::new("ghrm-scope-symlink");
        let external = TempDir::new("ghrm-external");
        fs::create_dir_all(external.path().join("secret")).unwrap();

        symlink(external.path(), td.path().join("escape")).unwrap();

        let (scope, _) = resolve_search_scope(td.path(), Some("escape"));
        assert!(scope.is_none());

        let (scope, _) = resolve_search_scope(td.path(), Some("escape/secret"));
        assert!(scope.is_none());
    }

    #[test]
    fn resolve_search_scope_returns_none_prefix_for_root() {
        let td = TempDir::new("ghrm-scope");

        let (scope, prefix) = resolve_search_scope(td.path(), None);
        assert_eq!(scope.unwrap(), td.path());
        assert!(prefix.is_none());

        let (scope, prefix) = resolve_search_scope(td.path(), Some(""));
        assert_eq!(scope.unwrap(), td.path());
        assert!(prefix.is_none());
    }

    #[test]
    fn scoped_content_search_returns_session_root_paths() {
        let td = TempDir::new("ghrm-scoped-search");
        fs::create_dir_all(td.path().join("plans/brege")).unwrap();
        fs::write(td.path().join("root.md"), "needle in root").unwrap();
        fs::write(td.path().join("plans/brege/bench.md"), "needle in child").unwrap();

        let resp = search::search(search::SearchOpts {
            query: "needle",
            session_root: td.path(),
            walk_root: &td.path().join("plans/brege"),
            use_ignore: false,
            hidden: true,
            exclude_names: &[],
            filter_exts: None,
            group_filter: None,
            max_rows: 10,
        });

        assert_eq!(resp.results.len(), 1);
        assert_eq!(resp.results[0].path, "plans/brege/bench.md");
    }
}
