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

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

use super::frontmatter::{FrontmatterShape, has_malformed_frontmatter, parse_frontmatter};
use super::helpers::{first_body_line, read, slugify};
use super::links::{
    MarkdownLinkTarget, classify_markdown_target, is_placeholder_wikilink, markdown_links,
    resolve_markdown_link_target, strip_code_fences, wikilink_targets,
};
use super::schema_profile::{FieldShape, aun_frontmatter_profile, kind_label};
use super::search::body_search_surfaces;
use super::target::resolve_target;

mod finding;
mod graph_rules;
mod model;
mod repair;
mod score;

pub use model::*;

use finding::*;
use graph_rules::*;
use repair::*;
use score::*;

fn validate_frontmatter(
    path: &Path,
    node: Option<&Node>,
    graph: &Graph,
    relation_fields: &BTreeMap<String, String>,
    options: QualityAuditOptions,
    findings: &mut Vec<QualityFinding>,
) {
    let text = read(path);
    let (fm, body) = parse_frontmatter(&text);
    let source = path.to_string_lossy().into_owned();
    let profile = aun_frontmatter_profile();
    let reserved_index = is_index_md(path);
    let reserved_log = is_log_md(path);
    let reserved_file = reserved_index || reserved_log;

    if has_malformed_frontmatter(&text) {
        findings.push(finding(
            QualityFindingKind::MalformedFrontmatter,
            DiagnosticSeverity::Warning,
            node,
            Some(source.clone()),
            None,
            None,
            "frontmatter block is unterminated".into(),
        ));
    }

    for field in profile.fields_with_shape(FieldShape::List) {
        if fm.shape(field) == Some(FrontmatterShape::Scalar) {
            findings.push(finding(
                QualityFindingKind::FrontmatterScalarListField,
                DiagnosticSeverity::Info,
                node,
                Some(source.clone()),
                Some(field),
                None,
                format!(
                    "frontmatter field `{field}` accepts a scalar but canonical form is a list"
                ),
            ));
        }
        if fm.get_list(field).iter().any(|item| item.trim().is_empty()) {
            findings.push(finding(
                QualityFindingKind::EmptyListItem,
                DiagnosticSeverity::Info,
                node,
                Some(source.clone()),
                Some(field),
                None,
                format!("frontmatter field `{field}` contains an empty list item"),
            ));
        }
    }
    for field in relation_fields.keys() {
        if fm.shape(field) == Some(FrontmatterShape::Scalar) {
            findings.push(finding(
                QualityFindingKind::FrontmatterScalarListField,
                DiagnosticSeverity::Info,
                node,
                Some(source.clone()),
                Some(field),
                None,
                format!(
                    "frontmatter field `{field}` accepts a scalar but canonical form is a list"
                ),
            ));
        }
        if fm.get_list(field).iter().any(|item| item.trim().is_empty()) {
            findings.push(finding(
                QualityFindingKind::EmptyListItem,
                DiagnosticSeverity::Info,
                node,
                Some(source.clone()),
                Some(field),
                None,
                format!("frontmatter field `{field}` contains an empty list item"),
            ));
        }
    }

    if let Some(status) = fm.get("status")
        && !profile.valid_statuses.contains(&status)
    {
        findings.push(finding(
            QualityFindingKind::InvalidStatus,
            DiagnosticSeverity::Warning,
            node,
            Some(source.clone()),
            Some("status"),
            Some(status.to_string()),
            format!(
                "frontmatter status `{status}` is not one of: {}",
                profile.valid_statuses.join(", ")
            ),
        ));
    }

    if let (Some(raw_type), Some(raw_kind)) = (fm.get("type"), fm.get("kind"))
        && Kind::normalize(raw_type) != Kind::normalize(raw_kind)
    {
        findings.push(finding(
            QualityFindingKind::ConflictingTypeKind,
            DiagnosticSeverity::Warning,
            node,
            Some(source.clone()),
            Some("kind"),
            Some(raw_kind.to_string()),
            format!("frontmatter type `{raw_type}` conflicts with kind `{raw_kind}`"),
        ));
    }

    for field in ["type", "kind"] {
        let Some(raw_type) = fm.get(field) else {
            continue;
        };
        let normalized = Kind::normalize(raw_type);
        if normalized == Kind::Unknown {
            findings.push(finding(
                QualityFindingKind::UnknownType,
                DiagnosticSeverity::Warning,
                node,
                Some(source.clone()),
                Some(field),
                Some(raw_type.to_string()),
                format!("frontmatter {field} `{raw_type}` normalizes to unknown"),
            ));
        } else {
            let canonical = kind_label(&normalized);
            if raw_type != canonical {
                findings.push(finding(
                    QualityFindingKind::TypeNormalized,
                    DiagnosticSeverity::Info,
                    node,
                    Some(source.clone()),
                    Some(field),
                    Some(raw_type.to_string()),
                    format!("frontmatter {field} `{raw_type}` normalizes to `{canonical}`"),
                ));
            }
        }
    }

    for key in fm.keys() {
        if is_authored_runtime_truth_field(key) {
            findings.push(finding(
                QualityFindingKind::AuthoredRuntimeTruth,
                DiagnosticSeverity::Warning,
                node,
                Some(source.clone()),
                Some(key),
                None,
                format!(
                    "frontmatter field `{key}` claims runtime truth Eidos computes — remove it; \
                     authored files declare identity/intent, never tier/confidence/score/freshness"
                ),
            ));
        } else if !profile.is_known_field(key) && !relation_fields.contains_key(key) {
            findings.push(finding(
                QualityFindingKind::UnknownFrontmatterField,
                DiagnosticSeverity::Info,
                node,
                Some(source.clone()),
                Some(key),
                None,
                format!("frontmatter field `{key}` is not used by Aun compile"),
            ));
        }
    }

    if !reserved_file && !has_non_empty_scalar(&fm, &["title", "name"]) {
        findings.push(finding(
            QualityFindingKind::TitleMissing,
            DiagnosticSeverity::Warning,
            node,
            Some(source.clone()),
            Some("title"),
            None,
            "markdown file has no non-empty title/name frontmatter".into(),
        ));
    }

    if !reserved_file && !has_non_empty_scalar(&fm, &["summary", "description"]) {
        findings.push(finding(
            QualityFindingKind::SummaryMissing,
            DiagnosticSeverity::Info,
            node,
            Some(source.clone()),
            Some("summary"),
            None,
            "markdown file has no non-empty summary/description frontmatter".into(),
        ));
    }

    if !reserved_file && fm.get("type").is_none() && fm.get("kind").is_none() {
        findings.push(finding(
            QualityFindingKind::TypeKindMissing,
            DiagnosticSeverity::Info,
            node,
            Some(source.clone()),
            Some("type"),
            None,
            "markdown file has no type/kind frontmatter".into(),
        ));
    }

    if !reserved_file
        && body_search_surfaces(body).is_empty()
        && fm.get_canonical_list("query_examples").is_empty()
    {
        findings.push(finding(
            QualityFindingKind::NoSearchableBody,
            DiagnosticSeverity::Warning,
            node,
            Some(source.clone()),
            None,
            None,
            "markdown file has no useful searchable body or query examples".into(),
        ));
    }

    for target in wikilink_targets(&strip_code_fences(body)) {
        if is_placeholder_wikilink(&target) {
            continue;
        }
        if resolve_target(&target, &graph.nodes).is_none() {
            findings.push(finding(
                QualityFindingKind::UnresolvedWikilink,
                DiagnosticSeverity::Warning,
                node,
                Some(source.clone()),
                None,
                Some(target.clone()),
                format!("wikilink target `{target}` does not resolve to a compiled node"),
            ));
        }
    }

    for link in markdown_links(body, 1) {
        match classify_markdown_target(&link.target) {
            MarkdownLinkTarget::External | MarkdownLinkTarget::NonMarkdown => {}
            MarkdownLinkTarget::Malformed => {
                findings.push(finding(
                    QualityFindingKind::MalformedInternalMarkdownLink,
                    DiagnosticSeverity::Warning,
                    node,
                    Some(source.clone()),
                    None,
                    Some(link.target.clone()),
                    "Markdown link has a malformed internal target".into(),
                ));
            }
            MarkdownLinkTarget::Internal(target) => {
                if resolve_markdown_link_target(path, &target, &graph.nodes).is_none() {
                    let kind = if reserved_index {
                        QualityFindingKind::BrokenIndexLink
                    } else {
                        QualityFindingKind::BrokenInternalMarkdownLink
                    };
                    let message = if reserved_index {
                        format!("index.md Markdown link target `{target}` does not resolve")
                    } else {
                        format!("Markdown link target `{target}` does not resolve")
                    };
                    findings.push(finding(
                        kind,
                        DiagnosticSeverity::Warning,
                        node,
                        Some(source.clone()),
                        None,
                        Some(target),
                        message,
                    ));
                }
            }
        }
    }

    if options.validate_relation_targets {
        for field in relation_fields.keys() {
            for target in fm.get_list(field) {
                if resolve_target(&target, &graph.nodes).is_none() {
                    findings.push(finding(
                        QualityFindingKind::UnresolvedFrontmatterTarget,
                        DiagnosticSeverity::Warning,
                        node,
                        Some(source.clone()),
                        Some(field),
                        Some(target.clone()),
                        format!(
                            "frontmatter `{field}` target `{target}` does not resolve to a compiled node"
                        ),
                    ));
                }
            }
        }
    }

    if reserved_index && !body_has_content(body) {
        findings.push(finding(
            QualityFindingKind::EmptyIndex,
            DiagnosticSeverity::Info,
            node,
            Some(source.clone()),
            None,
            None,
            "index.md has no directory overview content".into(),
        ));
    }

    if reserved_log {
        for line in body
            .lines()
            .filter(|line| line.trim_start().starts_with('#'))
        {
            if !is_date_heading(line) {
                findings.push(finding(
                    QualityFindingKind::MalformedLogDateHeading,
                    DiagnosticSeverity::Info,
                    node,
                    Some(source.clone()),
                    None,
                    Some(line.trim().to_string()),
                    "log.md heading is not a dated entry".into(),
                ));
            }
        }
    }

    if fm.get("summary").is_none()
        && fm.get("description").is_none()
        && first_body_line(body).is_empty()
        && !reserved_file
    {
        findings.push(finding(
            QualityFindingKind::WeakDoc,
            DiagnosticSeverity::Warning,
            node,
            Some(source),
            None,
            None,
            "markdown file has no summary/description or body text".into(),
        ));
    }
}

/// Runtime-truth frontmatter keys — values Eidos COMPUTES (never authored). Case-insensitive.
/// EKF.md "Authored vs Generated Fields" is the definitive list.
fn is_authored_runtime_truth_field(key: &str) -> bool {
    matches!(
        key.to_ascii_lowercase().as_str(),
        "tier" | "confidence" | "score" | "span" | "last_used" | "ground_count" | "freshness"
    )
}

fn graph_connectivity(graph: &Graph) -> (BTreeMap<String, usize>, BTreeMap<String, usize>) {
    let mut incoming = BTreeMap::new();
    let mut outgoing = BTreeMap::new();
    for edge in &graph.edges {
        if !is_trusted_relation(&edge.relation) {
            continue;
        }
        *outgoing.entry(edge.from.clone()).or_default() += 1;
        *incoming.entry(edge.to.clone()).or_default() += 1;
    }
    (incoming, outgoing)
}

struct AuthoredQueryExample<'a> {
    node: &'a Node,
    source: String,
}

fn authored_query_example_findings(
    markdown: &[std::path::PathBuf],
    by_source: &BTreeMap<String, &Node>,
    graph: &Graph,
    findings: &mut Vec<QualityFinding>,
) {
    let mut examples_by_slug: BTreeMap<String, Vec<AuthoredQueryExample<'_>>> = BTreeMap::new();
    for path in markdown {
        let source = path.to_string_lossy().into_owned();
        let Some(node) = by_source.get(&source).copied() else {
            continue;
        };
        let text = read(path);
        let (fm, _) = parse_frontmatter(&text);
        for example in fm.get_canonical_list("query_examples") {
            let slug = slugify(&example);
            if !slug.is_empty() {
                examples_by_slug
                    .entry(slug)
                    .or_default()
                    .push(AuthoredQueryExample {
                        node,
                        source: source.clone(),
                    });
            }
        }
    }

    for (slug, examples) in examples_by_slug {
        let ids = examples
            .iter()
            .map(|example| example.node.id.as_str())
            .collect::<BTreeSet<_>>();
        if ids.len() > 1 {
            for example in &examples {
                findings.push(finding(
                    QualityFindingKind::DuplicateQueryExample,
                    DiagnosticSeverity::Warning,
                    Some(example.node),
                    Some(example.source.clone()),
                    Some("query_examples"),
                    Some(slug.clone()),
                    format!(
                        "query example slug `{slug}` is shared by multiple nodes: {}",
                        ids.iter().copied().collect::<Vec<_>>().join(", ")
                    ),
                ));
            }
        }

        let title_nodes = graph
            .nodes
            .iter()
            .filter(|title_node| title_node.kind != Kind::Section)
            .filter(|title_node| slugify(&title_node.title) == slug)
            .collect::<Vec<_>>();
        for title_node in title_nodes {
            for example in examples
                .iter()
                .filter(|example| example.node.id != title_node.id)
            {
                findings.push(finding(
                    QualityFindingKind::QueryExampleDuplicatesTitle,
                    DiagnosticSeverity::Info,
                    Some(example.node),
                    Some(example.source.clone()),
                    Some("query_examples"),
                    Some(slug.clone()),
                    format!(
                        "query example slug `{slug}` on {} shadows title on {}",
                        example.node.id, title_node.id
                    ),
                ));
            }
        }
    }
}

pub fn audit_quality_with_options(
    markdown: &[std::path::PathBuf],
    graph: &Graph,
    relation_fields: &BTreeMap<String, String>,
    options: QualityAuditOptions,
) -> (Vec<QualityFinding>, Vec<NodeQualityScore>) {
    let mut by_source: BTreeMap<String, &Node> = BTreeMap::new();
    for node in &graph.nodes {
        if let Some(source) = node.source_files.first() {
            match by_source.get(source) {
                Some(existing) if existing.kind != Kind::Section => {}
                _ => {
                    by_source.insert(source.clone(), node);
                }
            }
        }
    }

    let mut findings = Vec::new();
    for path in markdown {
        let source = path.to_string_lossy().into_owned();
        validate_frontmatter(
            path,
            by_source.get(&source).copied(),
            graph,
            relation_fields,
            options,
            &mut findings,
        );
    }
    authored_query_example_findings(markdown, &by_source, graph, &mut findings);
    missing_index_findings(markdown, &mut findings);

    let (incoming, outgoing) = graph_connectivity(graph);
    graph_findings(graph, &incoming, &outgoing, &mut findings);
    findings.sort();
    findings.dedup();

    let scores = score_nodes(graph, &incoming, &outgoing, &findings);
    (findings, scores)
}

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

    fn node(id: &str, kind: Kind, title: &str, source: &str, span: Option<Span>) -> Node {
        Node {
            id: id.into(),
            kind,
            partition: None,
            subkind: None,
            title: title.into(),
            summary: "summary".into(),
            aliases: Vec::new(),
            tags: Vec::new(),
            query_examples: Vec::new(),
            source_files: vec![source.into()],
            span,
        }
    }

    #[test]
    fn file_level_diagnostics_prefer_document_node_over_generated_section() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("engine.md");
        std::fs::write(
            &path,
            "---\ntitle: Engine\nsummary: Compile graph diagnostics.\n---\n\n# Engine\n\nBody text.\n",
        )
        .unwrap();
        let source = path.to_string_lossy().into_owned();
        let graph = Graph {
            nodes: vec![
                node("doc.engine", Kind::Doc, "Engine", &source, None),
                node(
                    "doc.engine#engine",
                    Kind::Section,
                    "Engine",
                    &source,
                    Some(Span {
                        path: source.clone(),
                        start_line: 6,
                        end_line: 8,
                    }),
                ),
            ],
            ..Default::default()
        };

        let (findings, _) = audit_quality_with_options(
            &[path],
            &graph,
            &BTreeMap::new(),
            QualityAuditOptions::default(),
        );

        assert!(
            findings.iter().any(|finding| {
                finding.kind == QualityFindingKind::TypeKindMissing
                    && finding.node_id.as_deref() == Some("doc.engine")
            }),
            "type/kind frontmatter diagnostics should attach to the document node: {findings:#?}"
        );
        assert!(
            findings.iter().all(|finding| {
                finding.kind != QualityFindingKind::TypeKindMissing
                    || finding.node_id.as_deref() != Some("doc.engine#engine")
            }),
            "file-level diagnostics should not attach to generated sections: {findings:#?}"
        );
    }

    #[test]
    fn authored_query_example_collisions_are_reported() {
        let tmp = tempfile::tempdir().unwrap();
        let first = tmp.path().join("first.md");
        let second = tmp.path().join("second.md");
        std::fs::write(
            &first,
            "---\ntitle: First Policy\nsummary: First summary.\ntype: doc\nquery_examples:\n  - what should happen to failed messages\n---\n\nOperational body.\n",
        )
        .unwrap();
        std::fs::write(
            &second,
            "---\ntitle: Second Policy\nsummary: Second summary.\ntype: doc\nquery_examples:\n  - what should happen to failed messages\n---\n\nOperational body.\n",
        )
        .unwrap();
        let first_source = first.to_string_lossy().into_owned();
        let second_source = second.to_string_lossy().into_owned();
        let graph = Graph {
            nodes: vec![
                node("doc.first", Kind::Doc, "First Policy", &first_source, None),
                node(
                    "doc.second",
                    Kind::Doc,
                    "Second Policy",
                    &second_source,
                    None,
                ),
            ],
            ..Default::default()
        };

        let (findings, _) = audit_quality_with_options(
            &[first, second],
            &graph,
            &BTreeMap::new(),
            QualityAuditOptions::default(),
        );

        let duplicate_examples = findings
            .iter()
            .filter(|finding| finding.kind == QualityFindingKind::DuplicateQueryExample)
            .collect::<Vec<_>>();
        assert_eq!(
            duplicate_examples.len(),
            2,
            "each authored duplicate query example should be reported: {findings:#?}"
        );
        assert!(
            duplicate_examples
                .iter()
                .all(|finding| finding.field.as_deref() == Some("query_examples")),
            "finding should identify the authored frontmatter field"
        );
    }

    #[test]
    fn authored_query_examples_that_shadow_titles_are_reported() {
        let tmp = tempfile::tempdir().unwrap();
        let title = tmp.path().join("retry.md");
        let example = tmp.path().join("backoff.md");
        std::fs::write(
            &title,
            "---\ntitle: Retry Budget\nsummary: Retry summary.\ntype: doc\n---\n\nRetry body.\n",
        )
        .unwrap();
        std::fs::write(
            &example,
            "---\ntitle: Backoff Policy\nsummary: Backoff summary.\ntype: doc\nquery_examples:\n  - retry budget\n---\n\nBackoff body.\n",
        )
        .unwrap();
        let title_source = title.to_string_lossy().into_owned();
        let example_source = example.to_string_lossy().into_owned();
        let graph = Graph {
            nodes: vec![
                node("doc.retry", Kind::Doc, "Retry Budget", &title_source, None),
                node(
                    "doc.backoff",
                    Kind::Doc,
                    "Backoff Policy",
                    &example_source,
                    None,
                ),
            ],
            ..Default::default()
        };

        let (findings, _) = audit_quality_with_options(
            &[title, example],
            &graph,
            &BTreeMap::new(),
            QualityAuditOptions::default(),
        );

        assert!(
            findings.iter().any(|finding| {
                finding.kind == QualityFindingKind::QueryExampleDuplicatesTitle
                    && finding.node_id.as_deref() == Some("doc.backoff")
                    && finding.target.as_deref() == Some("retry-budget")
            }),
            "authored query_examples that shadow another title should be surfaced: {findings:#?}"
        );
    }

    #[test]
    fn generated_query_surfaces_are_not_authored_query_example_collisions() {
        let tmp = tempfile::tempdir().unwrap();
        let first = tmp.path().join("first.md");
        let second = tmp.path().join("second.md");
        std::fs::write(
            &first,
            "---\ntitle: First Policy\nsummary: First summary.\ntype: doc\n---\n\nShared body search surface.\n",
        )
        .unwrap();
        std::fs::write(
            &second,
            "---\ntitle: Second Policy\nsummary: Second summary.\ntype: doc\n---\n\nShared body search surface.\n",
        )
        .unwrap();
        let first_source = first.to_string_lossy().into_owned();
        let second_source = second.to_string_lossy().into_owned();
        let mut first_node = node("doc.first", Kind::Doc, "First Policy", &first_source, None);
        let mut second_node = node(
            "doc.second",
            Kind::Doc,
            "Second Policy",
            &second_source,
            None,
        );
        first_node.query_examples = vec!["Shared body search surface.".into()];
        second_node.query_examples = vec!["Shared body search surface.".into()];
        let graph = Graph {
            nodes: vec![first_node, second_node],
            ..Default::default()
        };

        let (findings, _) = audit_quality_with_options(
            &[first, second],
            &graph,
            &BTreeMap::new(),
            QualityAuditOptions::default(),
        );

        assert!(
            findings
                .iter()
                .all(|finding| finding.kind != QualityFindingKind::DuplicateQueryExample),
            "body-derived retrieval surfaces should not be treated as authored anchor collisions: {findings:#?}"
        );
    }
}
