use std::path::Path;
use std::sync::Arc;

use serde::Serialize;
use tracing::warn;

use crate::cache::NclCache;

#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ResultKind {
    Node,
    Adr,
    Mode,
}

#[derive(Debug, Serialize)]
pub struct SearchResult {
    pub kind: ResultKind,
    pub id: String,
    pub title: String,
    pub description: String,
    pub detail_html: String,
    pub path: String,
    pub pole: Option<String>,
    pub level: Option<String>,
}

/// Case-insensitive full-text search across ontology nodes, ADRs, and
/// reflection modes.
pub async fn search_project(
    root: &Path,
    cache: &Arc<NclCache>,
    import_path: Option<&str>,
    query: &str,
) -> Vec<SearchResult> {
    let q = query.to_lowercase();
    let mut results = Vec::new();

    // Ensure root is always in NICKEL_IMPORT_PATH so that cross-directory imports
    // like `import "reflection/defaults.ncl"` resolve from the project root,
    // regardless of whether the project has an explicit nickel_import_paths config.
    let root_str = root.display().to_string();
    let effective_ip = match import_path {
        Some(ip) if !ip.is_empty() => format!("{root_str}:{ip}"),
        _ => root_str,
    };
    let ip = Some(effective_ip.as_str());

    search_nodes(root, cache, ip, &q, &mut results).await;
    search_adrs(root, cache, ip, &q, &mut results).await;
    search_modes(root, cache, ip, &q, &mut results).await;

    results
}

async fn search_nodes(
    root: &Path,
    cache: &Arc<NclCache>,
    import_path: Option<&str>,
    q: &str,
    out: &mut Vec<SearchResult>,
) {
    let core_path = root.join(".ontology").join("core.ncl");
    if !core_path.exists() {
        return;
    }
    let Ok((json, _)) = cache.export(&core_path, import_path).await else {
        return;
    };
    let Some(nodes) = json.get("nodes").and_then(|n| n.as_array()) else {
        return;
    };
    for node in nodes {
        let id = str_field(node, "id");
        let name = str_field(node, "name");
        let desc = str_field(node, "description");
        if matches(q, &[id, name, desc]) {
            out.push(SearchResult {
                kind: ResultKind::Node,
                id: str_field(node, "id").to_string(),
                title: str_field(node, "name").to_string(),
                description: truncate(str_field(node, "description"), 180),
                detail_html: node_html(node),
                path: ".ontology/core.ncl".to_string(),
                pole: node.get("pole").and_then(|v| v.as_str()).map(String::from),
                level: node.get("level").and_then(|v| v.as_str()).map(String::from),
            });
        }
    }
}

async fn search_adrs(
    root: &Path,
    cache: &Arc<NclCache>,
    import_path: Option<&str>,
    q: &str,
    out: &mut Vec<SearchResult>,
) {
    let adrs_dir = root.join("adrs");
    let Ok(entries) = std::fs::read_dir(&adrs_dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
            continue;
        }
        let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
        // Only process actual ADR records: adr-NNN-*.ncl
        if !is_adr_record(fname) {
            continue;
        }
        match cache.export(&path, import_path).await {
            Ok((json, _)) => {
                let id = str_field(&json, "id");
                let title = str_field(&json, "title");
                let context = str_field(&json, "context");
                let decision = str_field(&json, "decision");
                // Also search constraint claims
                let constraint_text = json
                    .get("constraints")
                    .and_then(|v| v.as_array())
                    .map(|arr| {
                        arr.iter()
                            .filter_map(|c| c.get("claim").and_then(|v| v.as_str()))
                            .collect::<Vec<_>>()
                            .join(" ")
                    })
                    .unwrap_or_default();

                if matches(q, &[id, title, context, decision, &constraint_text]) {
                    let rel = path
                        .strip_prefix(root)
                        .unwrap_or(&path)
                        .to_string_lossy()
                        .to_string();
                    out.push(SearchResult {
                        kind: ResultKind::Adr,
                        id: str_field(&json, "id").to_string(),
                        title: str_field(&json, "title").to_string(),
                        description: truncate(str_field(&json, "context"), 180),
                        detail_html: adr_html(&json),
                        path: rel,
                        pole: None,
                        level: None,
                    });
                }
            }
            Err(e) => warn!(path = %path.display(), error = %e, "search: adr export failed"),
        }
    }
}

async fn search_modes(
    root: &Path,
    cache: &Arc<NclCache>,
    import_path: Option<&str>,
    q: &str,
    out: &mut Vec<SearchResult>,
) {
    let modes_dir = root.join("reflection").join("modes");
    let Ok(entries) = std::fs::read_dir(&modes_dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
            continue;
        }
        match cache.export(&path, import_path).await {
            Ok((json, _)) => {
                let id = str_field(&json, "id");
                let desc = str_field(&json, "description");
                if matches(q, &[id, desc]) {
                    let rel = path
                        .strip_prefix(root)
                        .unwrap_or(&path)
                        .to_string_lossy()
                        .to_string();
                    out.push(SearchResult {
                        kind: ResultKind::Mode,
                        id: str_field(&json, "id").to_string(),
                        title: str_field(&json, "id").to_string(),
                        description: truncate(str_field(&json, "description"), 180),
                        detail_html: mode_html(&json),
                        path: rel,
                        pole: None,
                        level: None,
                    });
                }
            }
            Err(e) => warn!(path = %path.display(), error = %e, "search: mode export failed"),
        }
    }
}

// ── Detail HTML builders ─────────────────────────────────────────────────────

const VIEWABLE_EXTS: &[&str] = &[
    "ncl", "toml", "rs", "nu", "md", "json", "html", "yaml", "yml",
];

fn node_html(n: &serde_json::Value) -> String {
    let desc = str_field(n, "description");
    let level = str_field(n, "level");
    let pole = str_field(n, "pole");
    let invariant = n
        .get("invariant")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let artifacts = n
        .get("artifact_paths")
        .and_then(|v| v.as_array())
        .map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
        .unwrap_or_default();
    let adrs: Vec<&str> = n
        .get("adrs")
        .and_then(|v| v.as_array())
        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
        .unwrap_or_default();

    let mut h = String::new();
    h.push_str(&para(desc));
    h.push_str("<div class=\"flex flex-wrap gap-1 my-2\">");
    h.push_str(&badge(level, "badge-ghost"));
    h.push_str(&badge(pole, "badge-ghost"));
    if invariant {
        h.push_str(&badge("invariant", "badge-warning"));
    }
    h.push_str("</div>");

    if !adrs.is_empty() {
        h.push_str(&section_header("Validated by"));
        h.push_str("<ul class=\"text-xs font-mono space-y-0.5\">");
        for adr in adrs {
            h.push_str(&format!(
                "<li><span class=\"text-success mr-1\">◆</span><button class=\"s-adr-link \
                 text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 \
                 cursor-pointer text-left\" data-adr=\"{id}\">{id}</button></li>",
                id = esc(adr)
            ));
        }
        h.push_str("</ul>");
    }

    if !artifacts.is_empty() {
        h.push_str(&section_header("Artifacts"));
        h.push_str("<ul class=\"text-xs font-mono space-y-1\">");
        for a in artifacts {
            let ext = a.rsplit('.').next().unwrap_or("");
            if VIEWABLE_EXTS.contains(&ext) {
                h.push_str(&format!(
                    "<li><button class=\"s-file-link text-primary hover:underline \
                     underline-offset-2 bg-transparent border-none p-0 cursor-pointer text-left \
                     break-all\" data-path=\"{path}\">{label}</button></li>",
                    path = esc(a),
                    label = esc(a)
                ));
            } else {
                h.push_str(&format!(
                    "<li class=\"text-base-content/60\">{}</li>",
                    esc(a)
                ));
            }
        }
        h.push_str("</ul>");
    }
    h
}

fn adr_html(json: &serde_json::Value) -> String {
    let mut h = String::new();

    if let Some(status) = json.get("status").and_then(|v| v.as_str()) {
        h.push_str(&format!(
            "<div class=\"mb-2\"><span class=\"badge badge-sm badge-outline\">{}</span></div>",
            esc(status)
        ));
    }

    for field in &["context", "decision"] {
        let label = if *field == "context" {
            "Context"
        } else {
            "Decision"
        };
        let text = str_field(json, field);
        if !text.is_empty() {
            h.push_str(&section_header(label));
            h.push_str(&text_block(text));
        }
    }

    if let Some(cons) = json.get("consequences") {
        h.push_str(&section_header("Consequences"));
        for (label, key) in &[("Positive", "positive"), ("Negative", "negative")] {
            let items = cons
                .get(key)
                .and_then(|v| v.as_array())
                .map(Vec::as_slice)
                .unwrap_or(&[]);
            if !items.is_empty() {
                h.push_str(&format!(
                    "<p class=\"text-xs font-medium text-base-content/60 mt-1 mb-0.5\">{label}</p>"
                ));
                h.push_str(&bullet_list(items));
            }
        }
    }

    if let Some(constraints) = json.get("constraints").and_then(|v| v.as_array()) {
        if !constraints.is_empty() {
            h.push_str(&section_header("Constraints"));
            for c in constraints {
                let claim = c.get("claim").and_then(|v| v.as_str()).unwrap_or("");
                let rationale = c.get("rationale").and_then(|v| v.as_str()).unwrap_or("");
                let severity = c.get("severity").and_then(|v| v.as_str()).unwrap_or("");
                let badge_cls = if severity == "Hard" {
                    "badge-error"
                } else {
                    "badge-warning"
                };
                h.push_str("<div class=\"border border-base-content/10 rounded p-2 mb-1.5\">");
                h.push_str(&format!(
                    "<div class=\"flex items-start gap-1.5 mb-1\"><span class=\"badge badge-xs \
                     {badge_cls} flex-shrink-0\">{severity}</span><p class=\"text-xs \
                     font-medium\">{}</p></div>",
                    esc(claim)
                ));
                if !rationale.is_empty() {
                    h.push_str(&format!(
                        "<p class=\"text-xs text-base-content/60\">{}</p>",
                        esc(rationale)
                    ));
                }
                h.push_str("</div>");
            }
        }
    }

    if let Some(alts) = json
        .get("alternatives_considered")
        .and_then(|v| v.as_array())
    {
        if !alts.is_empty() {
            h.push_str(&section_header("Alternatives Considered"));
            for alt in alts {
                let opt = alt.get("option").and_then(|v| v.as_str()).unwrap_or("");
                let why = alt
                    .get("why_rejected")
                    .and_then(|v| v.as_str())
                    .unwrap_or("");
                h.push_str("<div class=\"border border-base-content/10 rounded p-2 mb-1.5\">");
                h.push_str(&format!(
                    "<p class=\"text-xs font-medium mb-0.5\">{}</p>",
                    esc(opt)
                ));
                if !why.is_empty() {
                    h.push_str(&format!(
                        "<p class=\"text-xs text-base-content/50\">{}</p>",
                        esc(why)
                    ));
                }
                h.push_str("</div>");
            }
        }
    }

    h
}

fn mode_html(json: &serde_json::Value) -> String {
    let mut h = String::new();

    let desc = str_field(json, "description");
    if !desc.is_empty() {
        h.push_str(&para(desc));
    }

    for (label, key) in &[
        ("Preconditions", "preconditions"),
        ("Postconditions", "postconditions"),
    ] {
        let items = json
            .get(key)
            .and_then(|v| v.as_array())
            .map(Vec::as_slice)
            .unwrap_or(&[]);
        if !items.is_empty() {
            h.push_str(&section_header(label));
            h.push_str(&bullet_list(items));
        }
    }

    if let Some(steps) = json.get("steps").and_then(|v| v.as_array()) {
        if !steps.is_empty() {
            h.push_str(&section_header("Steps"));
            h.push_str("<ol class=\"space-y-1.5\">");
            for step in steps {
                let step_id = step.get("id").and_then(|v| v.as_str()).unwrap_or("");
                let actor = step.get("actor").and_then(|v| v.as_str()).unwrap_or("");
                let note = step.get("note").and_then(|v| v.as_str()).unwrap_or("");
                let cmd = step.get("cmd").and_then(|v| v.as_str()).unwrap_or("");
                h.push_str("<li class=\"border border-base-content/10 rounded p-2\">");
                h.push_str(&format!(
                    "<div class=\"flex items-center gap-1.5 mb-0.5\"><span class=\"badge badge-xs \
                     badge-ghost font-mono\">{}</span><span class=\"badge badge-xs \
                     badge-outline\">{}</span></div>",
                    esc(step_id),
                    esc(actor)
                ));
                if !note.is_empty() {
                    h.push_str(&format!(
                        "<p class=\"text-xs text-base-content/70\">{}</p>",
                        esc(note)
                    ));
                }
                if !cmd.is_empty() {
                    h.push_str(&format!(
                        "<pre class=\"text-xs font-mono bg-base-300 rounded px-2 py-1 mt-1 \
                         overflow-x-auto\">{}</pre>",
                        esc(cmd)
                    ));
                }
                h.push_str("</li>");
            }
            h.push_str("</ol>");
        }
    }

    h
}

// ── Helpers ──────────────────────────────────────────────────────────────────

/// Returns true only for actual ADR record files: `adr-NNN-*.ncl`
/// where NNN is one or more digits immediately after the second hyphen.
fn is_adr_record(fname: &str) -> bool {
    let Some(rest) = fname.strip_prefix("adr-") else {
        return false;
    };
    // next char must be a digit (adr-001-..., adr-1-..., etc.)
    rest.starts_with(|c: char| c.is_ascii_digit()) && fname.ends_with(".ncl")
}

fn str_field<'a>(v: &'a serde_json::Value, key: &str) -> &'a str {
    v.get(key).and_then(|v| v.as_str()).unwrap_or("")
}

fn matches(q: &str, fields: &[&str]) -> bool {
    fields.iter().any(|f| f.to_lowercase().contains(q))
}

fn truncate(s: &str, n: usize) -> String {
    if s.len() <= n {
        s.to_string()
    } else {
        format!("{}…", &s[..s.floor_char_boundary(n)])
    }
}

fn esc(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn section_header(label: &str) -> String {
    format!(
        "<p class=\"text-xs font-semibold text-base-content/40 uppercase tracking-wider mt-3 \
         mb-1\">{}</p>",
        esc(label)
    )
}

fn para(text: &str) -> String {
    let escaped = esc(text);
    // double-newline → paragraph break, single newline → <br>
    let paras: Vec<_> = escaped.split("\n\n").collect();
    paras
        .iter()
        .map(|p| {
            format!(
                "<p class=\"text-sm leading-relaxed\">{}</p>",
                p.replace('\n', "<br>")
            )
        })
        .collect::<Vec<_>>()
        .join("")
}

fn text_block(text: &str) -> String {
    format!(
        "<div class=\"text-sm leading-relaxed text-base-content/80 mb-1\">{}</div>",
        para(text)
    )
}

fn badge(text: &str, cls: &str) -> String {
    format!("<span class=\"badge badge-xs {cls}\">{}</span>", esc(text))
}

fn bullet_list(items: &[serde_json::Value]) -> String {
    let mut h = String::from(
        "<ul class=\"list-disc list-inside text-xs space-y-0.5 text-base-content/80\">",
    );
    for s in items.iter().filter_map(|v| v.as_str()) {
        h.push_str(&format!("<li>{}</li>", esc(s)));
    }
    h.push_str("</ul>");
    h
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── esc ───────────────────────────────────────────────────────────────

    #[test]
    fn esc_passthrough_plain() {
        assert_eq!(esc("hello world"), "hello world");
    }

    #[test]
    fn esc_all_special_chars() {
        assert_eq!(
            esc("<script>&\"</script>"),
            "&lt;script&gt;&amp;&quot;&lt;/script&gt;"
        );
    }

    #[test]
    fn esc_empty() {
        assert_eq!(esc(""), "");
    }

    // ── matches ───────────────────────────────────────────────────────────

    #[test]
    fn matches_case_insensitive_fields() {
        // q is pre-lowercased by the caller; fields are lowercased inside matches.
        assert!(matches("ontology", &["Ontology Design"]));
        assert!(matches("design", &["Ontology Design"]));
    }

    #[test]
    fn matches_any_field_sufficient() {
        assert!(matches("adr", &["unrelated", "ADR-001 migration"]));
    }

    #[test]
    fn matches_no_hit() {
        assert!(!matches("xyz", &["alpha", "beta", "gamma"]));
    }

    #[test]
    fn matches_empty_query_always_true() {
        // Empty string is contained in every string.
        assert!(matches("", &["anything"]));
    }

    // ── truncate ──────────────────────────────────────────────────────────

    #[test]
    fn truncate_short_string_unchanged() {
        assert_eq!(truncate("hello", 180), "hello");
    }

    #[test]
    fn truncate_at_boundary_unchanged() {
        let s = "a".repeat(180);
        assert_eq!(truncate(&s, 180), s);
    }

    #[test]
    fn truncate_long_string_appends_ellipsis() {
        let s = "a".repeat(200);
        let r = truncate(&s, 180);
        assert!(r.ends_with('…'));
        assert!(r.len() < 200);
    }

    // ── is_adr_record ─────────────────────────────────────────────────────

    #[test]
    fn is_adr_record_valid() {
        assert!(is_adr_record("adr-001-initial-decision.ncl"));
        assert!(is_adr_record("adr-1-something.ncl"));
        assert!(is_adr_record("adr-999-very-long-title.ncl"));
    }

    #[test]
    fn is_adr_record_rejects_non_numeric() {
        assert!(!is_adr_record("adr-schema.ncl"));
        assert!(!is_adr_record("adr-new_adr.ncl"));
    }

    #[test]
    fn is_adr_record_rejects_wrong_prefix() {
        assert!(!is_adr_record("schema.ncl"));
        assert!(!is_adr_record("001-something.ncl"));
    }

    #[test]
    fn is_adr_record_rejects_wrong_extension() {
        assert!(!is_adr_record("adr-001-title.toml"));
        assert!(!is_adr_record("adr-001-title"));
    }

    // ── para ──────────────────────────────────────────────────────────────

    #[test]
    fn para_single_line() {
        let r = para("hello world");
        assert!(r.contains("<p "));
        assert!(r.contains("hello world"));
        assert!(r.contains("</p>"));
    }

    #[test]
    fn para_double_newline_splits_paragraphs() {
        let r = para("first\n\nsecond");
        assert_eq!(r.matches("<p ").count(), 2);
    }

    #[test]
    fn para_single_newline_becomes_br() {
        let r = para("line one\nline two");
        assert!(r.contains("<br>"));
        assert!(!r.contains("line one\nline two"));
    }

    #[test]
    fn para_escapes_html_in_content() {
        let r = para("<b>bold</b>");
        assert!(r.contains("&lt;b&gt;"));
        assert!(!r.contains("<b>"));
    }

    // ── node_html ─────────────────────────────────────────────────────────

    #[test]
    fn node_html_escapes_malicious_content() {
        let node = serde_json::json!({
            "description": "<script>alert(1)</script>",
            "level": "core",
            "pole": "technical",
        });
        let html = node_html(&node);
        assert!(!html.contains("<script>"));
        assert!(html.contains("&lt;script&gt;"));
    }

    #[test]
    fn node_html_invariant_badge_present_when_true() {
        let node = serde_json::json!({
            "description": "desc",
            "invariant": true,
        });
        let html = node_html(&node);
        assert!(html.contains("invariant"));
        assert!(html.contains("badge-warning"));
    }

    #[test]
    fn node_html_no_invariant_badge_when_false() {
        let node = serde_json::json!({
            "description": "desc",
            "invariant": false,
        });
        let html = node_html(&node);
        assert!(!html.contains("badge-warning"));
    }

    // ── adr_html ──────────────────────────────────────────────────────────

    #[test]
    fn adr_html_escapes_claim_content() {
        let json = serde_json::json!({
            "constraints": [{
                "claim": "<inject>",
                "rationale": "r",
                "severity": "Hard",
            }]
        });
        let html = adr_html(&json);
        assert!(!html.contains("<inject>"));
        assert!(html.contains("&lt;inject&gt;"));
    }

    #[test]
    fn adr_html_hard_constraint_uses_error_badge() {
        let json = serde_json::json!({
            "constraints": [{"claim": "c", "rationale": "", "severity": "Hard"}]
        });
        assert!(adr_html(&json).contains("badge-error"));
    }

    #[test]
    fn adr_html_soft_constraint_uses_warning_badge() {
        let json = serde_json::json!({
            "constraints": [{"claim": "c", "rationale": "", "severity": "Soft"}]
        });
        assert!(adr_html(&json).contains("badge-warning"));
    }
}
