use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use eidos_kernel::schema::{Kind, Node, Span};

use super::frontmatter::parse_frontmatter;
use super::helpers::{first_body_line, read, slugify};
use super::search::query_surfaces_from_frontmatter_and_body;

/// Build a doc node from a single markdown file. The id is derived from the file PATH
/// (relative to `base`, extension dropped, slugified) so it's unique per file -- two
/// `wiki/index.md` files under different skills get distinct ids, not a collision.
pub(super) fn doc_node_from(
    md: &Path,
    base: &Path,
    node_kind_overrides: Option<&BTreeMap<String, String>>,
) -> Node {
    let text = read(md);
    let (fm, body) = parse_frontmatter(&text);
    let stem = md.file_stem().unwrap().to_string_lossy().into_owned();
    let title = fm
        .get("title")
        .or(fm.get("name"))
        .map_or(stem, str::to_string);
    let kind = fm
        .get("type")
        .or(fm.get("kind"))
        .map_or(Kind::Doc, |raw| Kind::resolve(raw, node_kind_overrides));
    // subkind = the author's raw domain type (lowercased), independent of the structural kind. None
    // for a plain doc with no declared type. Drives domain filtering/display, never scoring/structure.
    let subkind = fm
        .get("type")
        .or(fm.get("kind"))
        .map(|raw| raw.trim().to_ascii_lowercase());
    let summary = fm
        .get("summary")
        .or(fm.get("description"))
        .map_or_else(|| first_body_line(body), str::to_string);
    let mut aliases = fm.get_canonical_list("aliases");
    aliases.extend(fm.get_canonical_list("keywords"));
    let mut seen_aliases = BTreeSet::new();
    aliases.retain(|alias| seen_aliases.insert(alias.clone()));
    let tags = fm.get_canonical_list("tags");
    let rel = md.strip_prefix(base).unwrap_or(md).with_extension("");
    Node {
        id: format!("doc.{}", slugify(&rel.to_string_lossy())),
        kind,
        subkind,
        title,
        summary,
        aliases,
        tags,
        query_examples: query_surfaces_from_frontmatter_and_body(
            fm.get_canonical_list("query_examples"),
            body,
        ),
        source_files: vec![md.to_string_lossy().into_owned()],
        span: None,
        partition: None,
    }
}

/// A doc file → its whole-file Doc node PLUS one [`Kind::Section`] node per markdown heading. The Doc
/// is the front door (whole file); each Section is a heading-delimited block with its own `file:line`
/// span receipt, so grounding a concept inside a long doc can return the section, not the whole file.
/// Doc→Section is wired as `contains` edges in `build_edges` (structural/traversable) — deliberately
/// NOT `has_knowledge`, which would give docs the skill-front-door dominance boost and let any
/// doc-with-sections outrank real skills (the S3.5 real-vault regression). Sections compete on merit.
pub(super) fn doc_and_sections_from(
    md: &Path,
    base: &Path,
    node_kind_overrides: Option<&BTreeMap<String, String>>,
) -> Vec<Node> {
    let doc = doc_node_from(md, base, node_kind_overrides);
    let text = read(md);
    let mut out = section_nodes_from(md, &text, &doc.id);
    out.insert(0, doc);
    out
}

/// Parse one ATX heading line (`## Title`) → its text. `None` if the line isn't a heading. Requires
/// a space after the `#`s (so `#tag` / `#!shebang` aren't headings) and 1–6 levels.
fn atx_heading_text(line: &str) -> Option<String> {
    let t = line.trim_start();
    let hashes = t.chars().take_while(|&c| c == '#').count();
    if hashes == 0 || hashes > 6 || !t[hashes..].starts_with(' ') {
        return None;
    }
    let title = t[hashes..].trim().trim_end_matches('#').trim();
    (!title.is_empty()).then(|| title.to_string())
}

/// Split a doc's raw text into Section nodes — one per heading, spanning from the heading line to the
/// line before the next heading. Fenced code blocks (``` / ~~~) are skipped so `# comment` lines
/// inside code aren't mistaken for headings. Each section's body feeds its query surfaces so grounding
/// matches the section's *content*; the span is 1-indexed file lines (the receipt).
fn section_nodes_from(md: &Path, text: &str, doc_id: &str) -> Vec<Node> {
    let lines: Vec<&str> = text.lines().collect();
    let mut heads: Vec<(usize, String)> = Vec::new();
    let mut in_fence = false;
    for (i, line) in lines.iter().enumerate() {
        let t = line.trim_start();
        if t.starts_with("```") || t.starts_with("~~~") {
            in_fence = !in_fence;
            continue;
        }
        if !in_fence && let Some(title) = atx_heading_text(line) {
            heads.push((i, title));
        }
    }
    let path = md.to_string_lossy().into_owned();
    let mut nodes = Vec::with_capacity(heads.len());
    for (h, (start, title)) in heads.iter().enumerate() {
        let end = heads.get(h + 1).map_or(lines.len(), |(s, _)| *s);
        let slug = slugify(title);
        if slug.is_empty() {
            continue;
        }
        let body = lines[(start + 1)..end].join("\n");
        // a heading with no content under it is a structural divider, not a groundable section
        // (the parent Doc already carries the title) — skip it to avoid noise nodes.
        if body.trim().is_empty() {
            continue;
        }
        nodes.push(Node {
            id: format!("{doc_id}#{slug}"),
            kind: Kind::Section,
            subkind: None,
            title: title.clone(),
            summary: first_body_line(&body),
            aliases: Vec::new(),
            tags: Vec::new(),
            query_examples: query_surfaces_from_frontmatter_and_body(Vec::new(), &body),
            source_files: vec![path.clone()],
            partition: None,
            span: Some(Span {
                path: path.clone(),
                start_line: start + 1,
                end_line: end,
            }),
        });
    }
    nodes
}
