use std::collections::BTreeMap;
use std::path::Path;

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

use super::super::helpers::slugify;
use super::finding::*;
use super::model::*;

pub(crate) fn missing_index_findings(
    markdown: &[std::path::PathBuf],
    findings: &mut Vec<QualityFinding>,
) {
    let mut directories: BTreeMap<String, Vec<&Path>> = BTreeMap::new();
    for path in markdown {
        let Some(parent) = path.parent() else {
            continue;
        };
        if is_index_md(path) || is_log_md(path) {
            continue;
        }
        directories
            .entry(parent.to_string_lossy().into_owned())
            .or_default()
            .push(path.as_path());
    }

    for (directory, paths) in directories {
        if paths.len() < 2 {
            continue;
        }
        let index_path = Path::new(&directory).join("index.md");
        if index_path.is_file() {
            continue;
        }
        findings.push(finding(
            QualityFindingKind::MissingIndex,
            DiagnosticSeverity::Info,
            None,
            Some(directory.clone()),
            None,
            Some("index.md".into()),
            format!("directory `{directory}` has multiple markdown files but no index.md overview"),
        ));
    }
}

pub(crate) fn graph_findings(
    graph: &Graph,
    incoming: &BTreeMap<String, usize>,
    outgoing: &BTreeMap<String, usize>,
    findings: &mut Vec<QualityFinding>,
) {
    // Isolated nodes are collapsed into ONE summary finding: a vault of standalone docs would
    // otherwise flood the report with one identical finding per disconnected node. We keep the full
    // information (count + the list of ids) but emit a single finding. Orphan nodes (incoming-only
    // gap) stay per-node since they carry distinct per-node repair candidates.
    let mut isolated_ids: Vec<String> = Vec::new();
    for node in &graph.nodes {
        let has_incoming = incoming.get(&node.id).copied().unwrap_or_default() > 0;
        let has_outgoing = outgoing.get(&node.id).copied().unwrap_or_default() > 0;
        if !has_incoming && !has_outgoing {
            isolated_ids.push(node.id.clone());
        } else if !has_incoming && !is_linked_index_entrypoint(node) {
            findings.push(finding(
                QualityFindingKind::OrphanNode,
                DiagnosticSeverity::Info,
                Some(node),
                None,
                None,
                None,
                "node has no incoming trusted relation/link".into(),
            ));
        }
    }
    if !isolated_ids.is_empty() {
        isolated_ids.sort();
        let total = graph.nodes.len();
        let count = isolated_ids.len();
        // The ids ride the `target` field (comma-joined, sorted) so the full list is preserved and
        // the summary `finding_id` stays stable for a given set of isolated nodes.
        findings.push(finding(
            QualityFindingKind::IsolatedNode,
            DiagnosticSeverity::Warning,
            None,
            None,
            None,
            Some(isolated_ids.join(",")),
            format!("{count} of {total} nodes have no incoming or outgoing trusted relation/link"),
        ));
    }

    let mut by_slug: BTreeMap<String, Vec<&Node>> = BTreeMap::new();
    for node in &graph.nodes {
        let slug = slugify(&node.title);
        if !slug.is_empty() {
            by_slug.entry(slug).or_default().push(node);
        }
    }
    for (slug, nodes) in by_slug {
        if nodes.len() < 2 {
            continue;
        }
        if expected_doc_section_group(&nodes) {
            continue;
        }
        let ids = nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>();
        for node in nodes {
            findings.push(finding(
                QualityFindingKind::DuplicateTitle,
                DiagnosticSeverity::Warning,
                Some(node),
                None,
                Some("title"),
                Some(slug.clone()),
                format!(
                    "title slug `{slug}` is shared by multiple nodes: {}",
                    ids.join(", ")
                ),
            ));
        }
    }

    let mut aliases_by_slug: BTreeMap<String, Vec<&Node>> = BTreeMap::new();
    for node in &graph.nodes {
        for alias in &node.aliases {
            let slug = slugify(alias);
            if !slug.is_empty() {
                aliases_by_slug.entry(slug).or_default().push(node);
            }
        }
    }
    for (slug, nodes) in aliases_by_slug {
        let ids = nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>();
        if nodes.len() > 1 {
            for node in &nodes {
                findings.push(finding(
                    QualityFindingKind::DuplicateAlias,
                    DiagnosticSeverity::Info,
                    Some(node),
                    None,
                    Some("aliases"),
                    Some(slug.clone()),
                    format!(
                        "alias slug `{slug}` is shared by multiple nodes: {}",
                        ids.join(", ")
                    ),
                ));
            }
        }

        let title_nodes = graph
            .nodes
            .iter()
            .filter(|title_node| slugify(&title_node.title) == slug)
            .collect::<Vec<_>>();
        for title_node in title_nodes {
            for alias_node in nodes.iter().filter(|node| node.id != title_node.id) {
                findings.push(finding(
                    QualityFindingKind::AliasDuplicatesTitle,
                    DiagnosticSeverity::Info,
                    Some(alias_node),
                    None,
                    Some("aliases"),
                    Some(slug.clone()),
                    format!(
                        "alias slug `{slug}` on {} shadows title on {}",
                        alias_node.id, title_node.id
                    ),
                ));
            }
        }
    }

    let mut by_source: BTreeMap<String, Vec<&Node>> = BTreeMap::new();
    for node in &graph.nodes {
        for source in &node.source_files {
            by_source.entry(source.clone()).or_default().push(node);
        }
    }
    for (source, nodes) in by_source {
        if nodes.len() < 2 {
            continue;
        }
        if expected_doc_section_source_group(&nodes) {
            continue;
        }
        let ids = nodes
            .iter()
            .map(|node| node.id.as_str())
            .collect::<Vec<_>>();
        for node in nodes {
            findings.push(finding(
                QualityFindingKind::DuplicatePathIdentity,
                DiagnosticSeverity::Info,
                Some(node),
                Some(source.clone()),
                None,
                Some(source.clone()),
                format!(
                    "source path `{source}` is shared by multiple nodes: {}",
                    ids.join(", ")
                ),
            ));
        }
    }
}

fn expected_doc_section_group(nodes: &[&Node]) -> bool {
    nodes.len() == 2
        && (is_doc_section_pair(nodes[0], nodes[1]) || is_doc_section_pair(nodes[1], nodes[0]))
}

fn expected_doc_section_source_group(nodes: &[&Node]) -> bool {
    let docs = nodes
        .iter()
        .copied()
        .filter(|node| node.kind != Kind::Section)
        .collect::<Vec<_>>();
    docs.len() == 1
        && nodes
            .iter()
            .copied()
            .all(|node| node.id == docs[0].id || is_doc_section_pair(docs[0], node))
}

fn is_doc_section_pair(doc: &Node, section: &Node) -> bool {
    doc.kind != Kind::Section
        && section.kind == Kind::Section
        && section
            .id
            .strip_prefix(&doc.id)
            .is_some_and(|suffix| suffix.starts_with('#'))
        && same_primary_source(doc, section)
}

fn same_primary_source(left: &Node, right: &Node) -> bool {
    !left.source_files.is_empty() && left.source_files.first() == right.source_files.first()
}

fn is_linked_index_entrypoint(node: &Node) -> bool {
    node.kind != Kind::Section
        && node
            .source_files
            .first()
            .and_then(|source| Path::new(source).file_name())
            .is_some_and(|name| name == "index.md")
}

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

    fn doc(id: &str, title: &str, source: &str) -> Node {
        Node {
            id: id.into(),
            kind: Kind::Doc,
            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: None,
        }
    }

    fn section(doc_id: &str, title: &str, source: &str) -> Node {
        Node {
            id: format!("{doc_id}#{}", slugify(title)),
            kind: Kind::Section,
            partition: None,
            subkind: None,
            title: title.into(),
            summary: "section summary".into(),
            aliases: Vec::new(),
            tags: Vec::new(),
            query_examples: Vec::new(),
            source_files: vec![source.into()],
            span: Some(Span {
                path: source.into(),
                start_line: 1,
                end_line: 3,
            }),
        }
    }

    #[test]
    fn graph_findings_ignore_expected_doc_section_title_and_source_sharing() {
        let source = "docs/engine/architecture.md";
        let graph = Graph {
            nodes: vec![
                doc("doc.architecture", "Eidos Architecture", source),
                section("doc.architecture", "Eidos Architecture", source),
            ],
            ..Default::default()
        };

        let mut findings = Vec::new();
        graph_findings(&graph, &BTreeMap::new(), &BTreeMap::new(), &mut findings);

        assert!(
            findings
                .iter()
                .all(|finding| finding.kind != QualityFindingKind::DuplicateTitle
                    && finding.kind != QualityFindingKind::DuplicatePathIdentity),
            "expected doc-section representation noise to be suppressed: {findings:#?}"
        );
    }

    #[test]
    fn graph_findings_still_report_real_duplicate_titles() {
        let graph = Graph {
            nodes: vec![
                doc("doc.first", "Shared Title", "docs/first.md"),
                doc("doc.second", "Shared Title", "docs/second.md"),
            ],
            ..Default::default()
        };

        let mut findings = Vec::new();
        graph_findings(&graph, &BTreeMap::new(), &BTreeMap::new(), &mut findings);

        assert!(
            findings
                .iter()
                .any(|finding| finding.kind == QualityFindingKind::DuplicateTitle),
            "real title collisions should still be reported"
        );
    }

    #[test]
    fn graph_findings_do_not_report_linked_index_as_orphan() {
        let graph = Graph {
            nodes: vec![doc("doc.index", "Index", "docs/index.md")],
            ..Default::default()
        };
        let mut outgoing = BTreeMap::new();
        outgoing.insert("doc.index".to_string(), 1);

        let mut findings = Vec::new();
        graph_findings(&graph, &BTreeMap::new(), &outgoing, &mut findings);

        assert!(
            findings
                .iter()
                .all(|finding| finding.kind != QualityFindingKind::OrphanNode),
            "linked index.md entrypoints should not be reported as orphan source diagnostics"
        );
    }
}
