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

use aho_corasick::AhoCorasickBuilder;
use eidos_kernel::schema::relation::CONTAINS;
use eidos_kernel::schema::{Edge, EdgeBasis, EdgeEvidence, Kind, Node, Span};

use super::frontmatter::parse_frontmatter;
use super::helpers::read;
use super::links::{markdown_link_edges, wikilink_edges};
use super::mentions::mention_index;
use super::ownership::skill_wiki_edges;
use super::relations::frontmatter_relation_edges;

/// Emit edges from the compiled node set: skill->wiki `has_knowledge`, `[[wikilink]]`
/// `links_to`, typed frontmatter relations, and unlinked `mentions`. Two-pass -- targets
/// resolve against `nodes`.
pub(super) fn build_edges(nodes: &[Node], relation_fields: &BTreeMap<String, String>) -> Vec<Edge> {
    let mut edges = Vec::new();
    let mention_idx = mention_index(nodes);

    // Build Aho-Corasick automaton once from all mentionable forms.
    // This changes the mention scan from O(N×M) to O(N+M) — the critical fix for large vaults.
    let patterns: Vec<&str> = mention_idx.iter().map(|(form, _)| form.as_str()).collect();
    let ac = if patterns.is_empty() {
        None
    } else {
        Some(
            AhoCorasickBuilder::default()
                .build(&patterns)
                .expect("aho-corasick build failed"),
        )
    };

    for n in nodes {
        let Some(src) = n.source_files.first() else {
            continue;
        };
        let path = Path::new(src);
        let text = read(path);
        let (fm, body) = parse_frontmatter(&text);
        let body_start_line = line_number_for_slice_start(&text, body);

        edges.extend(skill_wiki_edges(n, path, nodes));
        edges.extend(wikilink_edges(n, path, body, body_start_line, nodes));
        edges.extend(markdown_link_edges(n, path, body, body_start_line, nodes));
        if n.span.is_none() {
            edges.extend(frontmatter_relation_edges(n, &fm, nodes, relation_fields));
        }

        // Aho-Corasick mention scan: one pass through the body, all forms matched simultaneously.
        if let Some(ref ac) = ac {
            let mut mention_count = 0u32;
            let mut seen_targets = std::collections::HashSet::new();
            let mention_lines = mention_scan_lines(n, &text, body, body_start_line);

            'scan: for (line_no, line) in mention_lines {
                let haystack = line.to_lowercase();
                let haystack_bytes = haystack.as_bytes();
                for mat in ac.find_iter(&haystack) {
                    let idx = mat.pattern();
                    let (_, target) = &mention_idx[idx];
                    if target == &n.id {
                        continue;
                    }

                    // Verify word boundaries
                    let start = mat.start();
                    let end = mat.end();
                    let before_ok =
                        start == 0 || !haystack_bytes[start - 1].is_ascii_alphanumeric();
                    let after_ok =
                        end == haystack_bytes.len() || !haystack_bytes[end].is_ascii_alphanumeric();
                    if !before_ok || !after_ok {
                        continue;
                    }

                    if seen_targets.insert(target.clone()) {
                        edges.push(Edge {
                            from: n.id.clone(),
                            to: target.clone(),
                            relation: eidos_kernel::schema::relation::MENTIONS.into(),
                            evidence: "mention".into(),
                            evidence_refs: vec![line_evidence("mention", src, line_no)],
                            // Mentions are lexical co-occurrence (Aho-Corasick), NOT authored
                            // links. The schema contract classes them Lexical (untrusted, not
                            // walked in expansion, synaptic weight 0.4); the old
                            // `..Default::default()` silently emitted them as Resolved — the
                            // opposite. See schema::EdgeBasis.
                            basis: EdgeBasis::Lexical,
                            ..Default::default()
                        });
                        mention_count += 1;
                        if mention_count >= 25 {
                            break 'scan;
                        }
                    }
                }
            }
        }
    }
    edges.extend(section_containment_edges(nodes));
    edges.sort_by(|a, b| (&a.from, &a.to, &a.relation).cmp(&(&b.from, &b.to, &b.relation)));
    edges.dedup_by(|a, b| a.from == b.from && a.to == b.to && a.relation == b.relation);
    edges
}

fn line_number_for_slice_start(text: &str, slice: &str) -> usize {
    let offset = text.len().saturating_sub(slice.len());
    text[..offset].bytes().filter(|byte| *byte == b'\n').count() + 1
}

fn mention_scan_lines<'a>(
    node: &Node,
    text: &'a str,
    body: &'a str,
    body_start_line: usize,
) -> Vec<(usize, &'a str)> {
    let mut lines = Vec::new();
    if let Some(title_line) = frontmatter_title_line(text, &node.title) {
        lines.push(title_line);
    }

    let mut in_fence = false;
    for (offset, line) in body.lines().enumerate() {
        if line.trim_start().starts_with("```") {
            in_fence = !in_fence;
            continue;
        }
        if !in_fence {
            lines.push((body_start_line + offset, line));
        }
    }
    lines
}

fn frontmatter_title_line<'a>(text: &'a str, title: &str) -> Option<(usize, &'a str)> {
    let title = title.trim();
    for (offset, line) in text.lines().enumerate() {
        if line.trim() == "---" && offset > 0 {
            return None;
        }
        let Some((key, value)) = line.split_once(':') else {
            continue;
        };
        if key.trim() == "title" && value.trim().trim_matches(['"', '\'']) == title {
            return Some((offset + 1, value));
        }
    }
    None
}

fn line_evidence(reason: &str, source: &str, line: usize) -> EdgeEvidence {
    EdgeEvidence {
        reason: reason.to_string(),
        source: Some(source.to_string()),
        span: Some(Span {
            path: source.to_string(),
            start_line: line,
            end_line: line,
        }),
    }
}

/// Doc → Section containment. A Section's id is `<doc_id>#<slug>`, so its parent is the prefix before
/// the first `#`. Emit a `contains` edge parent→section (structural, traversable in expansion) —
/// NOT `has_knowledge`: that relation drives canonical-dominance (the skill front-door boost), and
/// giving docs `has_knowledge` children would inflate every doc-with-sections into a "front door"
/// that outranks the actual skills (the S3.5 real-vault regression). Sections compete on their own
/// merit; only true skill→wiki ownership earns dominance.
fn section_containment_edges(nodes: &[Node]) -> Vec<Edge> {
    let ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
    nodes
        .iter()
        .filter(|n| n.kind == Kind::Section)
        .filter_map(|n| {
            let parent = n.id.split_once('#')?.0;
            ids.contains(parent).then(|| Edge {
                from: parent.to_string(),
                to: n.id.clone(),
                relation: CONTAINS.to_string(),
                evidence: "section of".to_string(),
                // Structural (heading hierarchy) — earned Resolved.
                basis: EdgeBasis::Resolved,
                ..Default::default()
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use eidos_kernel::schema::EdgeBasis;
    use eidos_kernel::schema::relation::MENTIONS;
    use std::path::Path;

    fn write(path: &Path, body: &str) {
        std::fs::write(path, body).unwrap();
    }

    /// Regression: lexical `mentions` co-occurrence edges must carry `EdgeBasis::Lexical`, not the
    /// `Resolved` default. Emitting them Resolved contradicts the schema contract (mentions are
    /// untrusted, not walked in expansion) and would let weak co-occurrence drive trusted traversal.
    #[test]
    fn mention_edges_are_lexical() {
        let tmp = tempfile::tempdir().unwrap();
        write(
            &tmp.path().join("starter.md"),
            "---\ntitle: Sourdough Starter\nsummary: \"Wild yeast.\"\n---\nFeed it daily.\n",
        );
        write(
            &tmp.path().join("bake.md"),
            "---\ntitle: Weekend Bake\nsummary: \"A plan.\"\n---\nUse your Sourdough Starter today.\n",
        );

        let graph = super::super::build_graph(tmp.path(), tmp.path());
        let mentions: Vec<_> = graph
            .edges
            .iter()
            .filter(|e| e.relation == MENTIONS)
            .collect();
        assert!(!mentions.is_empty(), "expected a mention edge to form");
        assert!(
            mentions.iter().all(|e| e.basis == EdgeBasis::Lexical),
            "mention edges must be Lexical, not Resolved"
        );
    }

    #[test]
    fn authored_edges_carry_line_span_evidence() {
        let tmp = tempfile::tempdir().unwrap();
        write(
            &tmp.path().join("target.md"),
            "---\ntitle: Target Doc\nsummary: \"Target.\"\n---\n\n# Target Doc\n\nTarget body.\n",
        );
        write(
            &tmp.path().join("other.md"),
            "---\ntitle: Other Doc\nsummary: \"Other.\"\n---\n\n# Other Doc\n\nOther body.\n",
        );
        write(
            &tmp.path().join("links.md"),
            "---\ntitle: Links Doc\nsummary: \"Links.\"\n---\n\n[[Target Doc]]\n\n[Other](other.md)\n",
        );
        write(
            &tmp.path().join("mentions.md"),
            "---\ntitle: Mentions Doc\nsummary: \"Mentions.\"\n---\n\nThis mentions Target Doc plainly.\n",
        );

        let graph = super::super::build_graph(tmp.path(), tmp.path());
        let links = graph
            .edges
            .iter()
            .filter(|edge| {
                edge.relation == eidos_kernel::schema::relation::LINKS_TO
                    && (edge.to == "doc.target" || edge.to == "doc.other")
            })
            .collect::<Vec<_>>();
        assert_eq!(links.len(), 2);
        assert!(
            links
                .iter()
                .all(|edge| edge.evidence_refs.iter().any(|evidence| evidence
                    .span
                    .as_ref()
                    .is_some_and(
                        |span| span.path.ends_with(".md") && span.start_line == span.end_line
                    )))
        );

        let mention = graph
            .edges
            .iter()
            .find(|edge| {
                edge.relation == MENTIONS && edge.from == "doc.mentions" && edge.to == "doc.target"
            })
            .expect("mention edge should resolve to target doc");
        assert!(
            mention.evidence_refs.iter().any(|evidence| evidence
                .span
                .as_ref()
                .is_some_and(|span| span.start_line == 6 && span.end_line == 6)),
            "mention should carry the source line span"
        );
    }

    #[test]
    fn frontmatter_relation_edges_apply_to_whole_file_nodes_only() {
        let tmp = tempfile::tempdir().unwrap();
        write(
            &tmp.path().join("overview.md"),
            "---\ntitle: Overview\nsummary: \"Overview.\"\ndepends_on:\n  - Target Doc\n---\n\n# Overview\n\nSection body.\n",
        );
        write(
            &tmp.path().join("target.md"),
            "---\ntitle: Target Doc\nsummary: \"Target.\"\n---\n\n# Target Doc\n\nTarget body.\n",
        );

        let graph = super::super::build_graph(tmp.path(), tmp.path());
        let relations = graph
            .edges
            .iter()
            .filter(|edge| edge.relation == "depends_on")
            .collect::<Vec<_>>();

        assert_eq!(relations.len(), 1);
        assert_eq!(relations[0].from, "doc.overview");
        assert_eq!(relations[0].to, "doc.target");
    }
}
