//! Glyph — the code source (tree-sitter → nodes with spans).
//!
//! Reads source files (Rust, Python, TypeScript) via tree-sitter, extracts symbol nodes
//! (Function / Type / Trait / Module) with their `file:line` spans, and emits the same
//! [`Graph`] the docs compiler emits. This is the CODE memory.
//!
//! [`Graph`]: eidos_kernel::schema::Graph

mod coverage;
mod cross;
mod diagnostics;
mod extract;
mod graph;
mod language;
mod project;
mod rank;
mod read;
mod references;
mod structure;
mod walk;

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

use crate::source::{Source as SourceTrait, SourceOutput};
use eidos_kernel::schema::{Edge, EdgeBasis as CoreEdgeBasis, Graph, Kind, Node, relation};

use coverage::{count_edges_by_relation, count_nodes_by_kind};
use diagnostics::{DiagnosticsInput, build_diagnostics};
use graph::RefName;
use project::annotate_project_zones;
use rank::annotate_reference_evidence;
use references::{build_references, reference_evidence, resolve_implements, structural_evidence};
use walk::walk_code;

pub use coverage::{CodeCompileReport, CodeCoverage};
pub use cross::{CrossCase, CrossReport, cross_domain_eval, link};
pub use diagnostics::{
    DuplicateNodeIdDiagnostic, FileZoneDiagnostic, GlyphDiagnosticFinding, GlyphDiagnosticKind,
    GlyphDiagnostics, GlyphFileDiagnostic, NodeZoneDiagnostic, SourceRootDiagnostic,
    SpanlessNodeDiagnostic,
};
pub use project::{ProjectZone, ProjectZoneAssignment, ProjectZoneConfidence};
pub use read::{SpanRead, read_span};
pub use references::{
    EdgeBasis, ProvEdge, ReferenceBasis, ReferenceEvidence, resolve_edges, resolved_incoming_deps,
};
pub use structure::{StructureDiff, structure_diff};
pub use walk::CodeWalkOptions;

/// The Glyph source: compiles a code root into graph nodes and edges.
#[derive(Debug, Clone, Default)]
pub struct Glyph {
    /// Declared extraction languages for this root (empty = all supported).
    languages: Vec<String>,
}

impl Glyph {
    pub fn new() -> Self {
        Self::default()
    }

    /// Restrict extraction to the declared language labels ("rust", "python", "typescript",
    /// "tsx"). This is the EKF `languages:` contract reaching extraction.
    pub fn with_languages(mut self, languages: Vec<String>) -> Self {
        self.languages = languages;
        self
    }
}

impl SourceTrait for Glyph {
    fn name(&self) -> &str {
        "glyph"
    }

    fn compile(&self, root: &Path) -> SourceOutput {
        let report = compile_code_report_with_options(
            root,
            CodeWalkOptions {
                languages: self.languages.clone(),
                ..CodeWalkOptions::default()
            },
        );
        SourceOutput {
            nodes: report.graph.nodes,
            edges: report.graph.edges,
            pending_edges: Vec::new(),
            diagnostics: report
                .diagnostics
                .findings
                .into_iter()
                .map(|finding| crate::source::SourceDiagnostic {
                    message: finding.message,
                    severity: glyph_diagnostic_severity(&finding.kind).into(),
                    kind: serde_json::to_value(&finding.kind)
                        .ok()
                        .and_then(|value| value.as_str().map(str::to_string))
                        .unwrap_or_else(|| format!("{:?}", finding.kind)),
                    source: finding.path.or(finding.root),
                    node_id: finding.node_id,
                    suggested_action: glyph_diagnostic_action(&finding.kind).map(str::to_string),
                    repair_tool: None,
                })
                .collect(),
        }
    }
}

fn glyph_diagnostic_severity(kind: &GlyphDiagnosticKind) -> &'static str {
    match kind {
        GlyphDiagnosticKind::DuplicateNodeId => "error",
        GlyphDiagnosticKind::ParseError
        | GlyphDiagnosticKind::SourceRootNoParsedFiles
        | GlyphDiagnosticKind::SpanlessNode => "warning",
        GlyphDiagnosticKind::UnsupportedCommonExtension
        | GlyphDiagnosticKind::LanguageExcludedByContract => "info",
    }
}

fn glyph_diagnostic_action(kind: &GlyphDiagnosticKind) -> Option<&'static str> {
    match kind {
        GlyphDiagnosticKind::ParseError => {
            Some("Inspect the file for syntax that prevents reliable tree-sitter extraction.")
        }
        GlyphDiagnosticKind::UnsupportedCommonExtension => Some(
            "Add a Glyph extractor for this language or exclude the directory from code roots.",
        ),
        GlyphDiagnosticKind::LanguageExcludedByContract => Some(
            "Review the EKF languages contract for this code root or move the file to a matching partition.",
        ),
        GlyphDiagnosticKind::SourceRootNoParsedFiles => {
            Some("Point the code root at supported Rust, Python, or TypeScript sources.")
        }
        GlyphDiagnosticKind::DuplicateNodeId => {
            Some("Make extracted symbol identities unique before trusting this code graph.")
        }
        GlyphDiagnosticKind::SpanlessNode => {
            Some("Fix the extractor so the symbol node carries a source span.")
        }
    }
}

/// Walk source files under `root` and extract symbol nodes + structural edges into a `Graph`.
pub fn compile_code(root: &Path) -> Graph {
    compile_code_report(root).graph
}

/// Compile `root` and include coverage facts.
pub fn compile_code_report(root: &Path) -> CodeCompileReport {
    compile_code_report_with_options(root, CodeWalkOptions::default())
}

/// Compile `root` with explicit source-scope excludes and include coverage facts.
pub fn compile_code_report_with_options(
    root: &Path,
    options: CodeWalkOptions,
) -> CodeCompileReport {
    let mut files = Vec::new();
    let mut ignored = BTreeMap::new();
    let mut ignored_files = Vec::new();
    let mut excluded_paths = Vec::new();
    let mut language_excluded_files = Vec::new();
    walk_code(
        root,
        &mut files,
        &mut ignored,
        &mut ignored_files,
        &mut excluded_paths,
        &options,
    );
    files.sort_by(|a, b| a.0.cmp(&b.0));
    ignored_files.sort_by(|a, b| a.path.cmp(&b.path));
    excluded_paths.sort_by(|a, b| a.path.cmp(&b.path));

    // Declared languages are an extraction contract: keep only files whose language label the
    // root declared; count the rest so coverage says what the contract excluded.
    let mut files_excluded_by_language = BTreeMap::new();
    if !options.languages.is_empty() {
        let declared: std::collections::BTreeSet<&str> =
            options.languages.iter().map(String::as_str).collect();
        files.retain(|(path, meta)| {
            let label = meta.language.label();
            if declared.contains(label) {
                true
            } else {
                *files_excluded_by_language
                    .entry(label.to_string())
                    .or_insert(0) += 1;
                language_excluded_files.push((path.clone(), meta.language));
                false
            }
        });
    }
    language_excluded_files.sort_by(|a, b| a.0.cmp(&b.0));

    let mut nodes = Vec::new();
    let mut edges = Vec::new();
    let mut file_refs: Vec<(String, Vec<RefName>)> = Vec::new();
    let mut language_counts = BTreeMap::new();
    let mut parse_error_files = Vec::new();
    for (path, meta) in &files {
        *language_counts
            .entry(meta.language.label().to_string())
            .or_insert(0) += 1;
        let fg = extract::symbols_in_file_with_language(path, root, meta.language);
        if fg.parse_error {
            parse_error_files.push((path.clone(), meta.language));
        }
        nodes.extend(fg.nodes);
        edges.extend(fg.edges);
        file_refs.extend(fg.refs);
    }
    link_module_hierarchy(&nodes, &mut edges);
    resolve_split_impl_containers(&nodes, &mut edges);
    resolve_implements(&nodes, &mut edges);
    drop_external_implements(&nodes, &mut edges);
    annotate_project_zones(&mut nodes, root);
    let mut edge_evidence = structural_evidence(&edges);
    edge_evidence.extend(reference_evidence(&nodes, &file_refs));
    build_references(&nodes, &file_refs, &mut edges);
    annotate_reference_evidence(&mut nodes, &edges);
    let graph = Graph {
        nodes,
        edges,
        ..Default::default()
    };
    let ignored_file_count = ignored.values().sum::<usize>();
    let mut files_walked_paths = files
        .iter()
        .map(|(path, _)| path.to_string_lossy().into_owned())
        .chain(
            ignored_files
                .iter()
                .map(|ignored| ignored.path.to_string_lossy().into_owned()),
        )
        .chain(
            language_excluded_files
                .iter()
                .map(|(path, _)| path.to_string_lossy().into_owned()),
        )
        .collect::<Vec<_>>();
    files_walked_paths.sort();
    let files_ignored_paths = ignored_files
        .iter()
        .map(|ignored| ignored.path.to_string_lossy().into_owned())
        .collect::<Vec<_>>();
    let files_excluded_by_language_paths = language_excluded_files
        .iter()
        .map(|(path, _)| path.to_string_lossy().into_owned())
        .collect::<Vec<_>>();
    let source_scope_excluded_paths = excluded_paths
        .iter()
        .map(|excluded| excluded.path.to_string_lossy().into_owned())
        .collect::<Vec<_>>();
    let coverage = CodeCoverage {
        files_excluded_by_language,
        files_walked: files.len() + ignored_file_count + language_excluded_files.len(),
        files_scanned: files.len(),
        files_ignored_by_extension: ignored,
        files_excluded_by_scope: excluded_paths.len(),
        source_scope_excluded_paths,
        files_walked_paths,
        files_ignored_paths,
        files_excluded_by_language_paths,
        language_counts,
        node_counts_by_kind: count_nodes_by_kind(&graph.nodes),
        edge_counts_by_relation: count_edges_by_relation(&graph.edges),
    };
    let diagnostics = build_diagnostics(DiagnosticsInput {
        root,
        supported_files: &files,
        ignored_files: &ignored_files,
        language_excluded_files: &language_excluded_files,
        excluded_paths: &excluded_paths,
        parse_error_files: &parse_error_files,
        graph: &graph,
        reference_evidence: edge_evidence,
    });
    CodeCompileReport {
        graph,
        coverage,
        diagnostics,
    }
}

fn link_module_hierarchy(nodes: &[Node], edges: &mut Vec<Edge>) {
    let modules = nodes
        .iter()
        .filter(|node| node.kind == Kind::Module)
        .map(|node| (node.id.clone(), node))
        .collect::<BTreeMap<_, _>>();
    if modules.len() < 2 {
        return;
    }

    let root = source_root_module_id(modules.keys());
    let mut existing = edges
        .iter()
        .filter(|edge| edge.relation == relation::CONTAINS)
        .map(|edge| (edge.from.clone(), edge.to.clone()))
        .collect::<BTreeSet<_>>();

    for (child_id, child) in &modules {
        let Some(parent_id) = parent_module_id(child_id, &modules, root.as_deref()) else {
            continue;
        };
        if parent_id == *child_id || !existing.insert((parent_id.clone(), child_id.clone())) {
            continue;
        }
        edges.push(Edge {
            from: parent_id,
            to: child_id.clone(),
            relation: relation::CONTAINS.into(),
            evidence: module_hierarchy_evidence(child),
            basis: CoreEdgeBasis::Resolved,
            ..Default::default()
        });
    }
}

fn source_root_module_id<'a>(module_ids: impl Iterator<Item = &'a String>) -> Option<String> {
    let ids = module_ids.map(String::as_str).collect::<BTreeSet<_>>();
    ["mod.lib", "mod.main", "mod.index", "mod.__init__"]
        .into_iter()
        .find(|candidate| ids.contains(candidate))
        .map(str::to_string)
}

fn parent_module_id(
    child_id: &str,
    modules: &BTreeMap<String, &Node>,
    source_root: Option<&str>,
) -> Option<String> {
    let path = child_id.strip_prefix("mod.")?;
    if let Some((parent_path, _)) = path.rsplit_once("::") {
        let parent_id = format!("mod.{parent_path}");
        if modules.contains_key(&parent_id) {
            return Some(parent_id);
        }
    }
    source_root
        .filter(|root| *root != child_id && !is_source_root_module(child_id))
        .map(str::to_string)
}

fn module_hierarchy_evidence(node: &Node) -> String {
    node.span
        .as_ref()
        .map(|span| {
            format!(
                "module hierarchy {}:{}-{}",
                span.path, span.start_line, span.end_line
            )
        })
        .unwrap_or_else(|| "module hierarchy".to_string())
}

fn is_source_root_module(id: &str) -> bool {
    matches!(id, "mod.lib" | "mod.main" | "mod.index" | "mod.__init__")
}

fn resolve_split_impl_containers(nodes: &[Node], edges: &mut [Edge]) {
    let node_ids = nodes
        .iter()
        .map(|node| node.id.as_str())
        .collect::<BTreeSet<_>>();
    let type_nodes = nodes
        .iter()
        .filter(|node| node.kind == Kind::Type)
        .collect::<Vec<_>>();

    for edge in edges.iter_mut().filter(|edge| {
        edge.relation == relation::CONTAINS && !node_ids.contains(edge.from.as_str())
    }) {
        let Some((impl_modpath, type_name)) = split_type_id(&edge.from) else {
            continue;
        };
        let mut candidates = type_nodes
            .iter()
            .filter_map(|node| {
                let (candidate_modpath, candidate_name) = split_type_id(&node.id)?;
                (candidate_name == type_name
                    && (impl_modpath == candidate_modpath
                        || impl_modpath.starts_with(&format!("{candidate_modpath}::"))))
                .then_some((candidate_modpath, node.id.as_str()))
            })
            .collect::<Vec<_>>();
        candidates.sort_by(|(a, _), (b, _)| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
        candidates.dedup_by(|(_, a), (_, b)| a == b);
        if candidates.len() == 1 {
            edge.from = candidates[0].1.to_string();
        }
    }
}

fn split_type_id(id: &str) -> Option<(&str, &str)> {
    let path = id.strip_prefix("type.")?;
    path.rsplit_once("::")
}

fn drop_external_implements(nodes: &[Node], edges: &mut Vec<Edge>) {
    let node_ids = nodes
        .iter()
        .map(|node| node.id.as_str())
        .collect::<BTreeSet<_>>();
    edges.retain(|edge| {
        edge.relation != relation::IMPLEMENTS || node_ids.contains(edge.to.as_str())
    });
}

#[cfg(test)]
mod source_tests {
    use super::*;
    use crate::Source;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicU32, Ordering};

    #[test]
    fn source_output_includes_glyph_findings() {
        let root = unique_dir("glyph-source-diagnostics");
        std::fs::write(root.join("schema.sql"), "select 1;\n").unwrap();

        let out = Glyph::new().compile(&root);

        assert!(out.nodes.is_empty());
        assert!(
            out.diagnostics
                .iter()
                .any(|diagnostic| diagnostic.message.contains("unsupported .sql file"))
        );

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn language_contract_exclusions_are_visible_diagnostics() {
        let root = unique_dir("glyph-language-contract-diagnostics");
        std::fs::write(root.join("lib.rs"), "pub fn rust_entry() {}\n").unwrap();
        std::fs::write(root.join("app.ts"), "export function tsEntry() {}\n").unwrap();

        let out = Glyph::new()
            .with_languages(vec!["rust".to_string()])
            .compile(&root);

        assert!(out.nodes.iter().any(|node| node.id.contains("rust_entry")));
        assert!(!out.nodes.iter().any(|node| node.id.contains("tsEntry")));
        let diagnostic = out
            .diagnostics
            .iter()
            .find(|diagnostic| diagnostic.kind == "language_excluded_by_contract")
            .expect("language exclusions should be source diagnostics");
        assert_eq!(diagnostic.severity, "info");
        assert_eq!(
            diagnostic.source.as_deref(),
            Some(root.join("app.ts").to_str().unwrap())
        );
        assert!(
            diagnostic
                .suggested_action
                .as_deref()
                .is_some_and(|action| action.contains("EKF languages contract"))
        );

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn language_contract_exclusions_stay_in_coverage_paths() {
        let root = unique_dir("glyph-language-contract-coverage");
        let rust_path = root.join("lib.rs");
        let ts_path = root.join("app.ts");
        std::fs::write(&rust_path, "pub fn rust_entry() {}\n").unwrap();
        std::fs::write(&ts_path, "export function tsEntry() {}\n").unwrap();

        let report = compile_code_report_with_options(
            &root,
            CodeWalkOptions {
                languages: vec!["rust".to_string()],
                ..CodeWalkOptions::default()
            },
        );

        assert_eq!(report.coverage.files_walked, 2);
        assert_eq!(report.coverage.files_scanned, 1);
        assert_eq!(
            report.coverage.files_excluded_by_language.get("typescript"),
            Some(&1)
        );
        assert_eq!(
            report.coverage.files_excluded_by_language_paths,
            vec![ts_path.to_string_lossy().into_owned()]
        );
        assert!(
            report
                .coverage
                .files_walked_paths
                .contains(&rust_path.to_string_lossy().into_owned())
        );
        assert!(
            report
                .coverage
                .files_walked_paths
                .contains(&ts_path.to_string_lossy().into_owned())
        );
        assert_eq!(
            report
                .diagnostics
                .files_excluded_by_language
                .get("typescript"),
            Some(&1)
        );

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn undocumented_module_nodes_get_structural_summaries() {
        let root = unique_dir("glyph-module-summaries");
        std::fs::write(root.join("lib.rs"), "pub fn run() {}\n").unwrap();
        std::fs::write(root.join("service.py"), "def run():\n    pass\n").unwrap();
        std::fs::write(root.join("widget.ts"), "export function run() {}\n").unwrap();

        let graph = compile_code(&root);

        assert!(
            graph
                .nodes
                .iter()
                .any(|node| { node.id == "mod.lib" && node.summary == "lib — rust module" })
        );
        assert!(graph.nodes.iter().any(|node| {
            node.id == "mod.service" && node.summary == "service — python module"
        }));
        assert!(graph.nodes.iter().any(|node| {
            node.id == "mod.widget" && node.summary == "widget — typescript module"
        }));

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn glyph_connects_leaf_modules_into_source_hierarchy() {
        let root = unique_dir("glyph-module-hierarchy");
        std::fs::create_dir_all(root.join("engine")).unwrap();
        std::fs::write(root.join("lib.rs"), "//! crate root\npub mod engine;\n").unwrap();
        std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
        std::fs::write(
            root.join("calibration.rs"),
            "//! ranking constants live here\n",
        )
        .unwrap();
        std::fs::write(root.join("engine.rs"), "pub mod leaf;\npub fn run() {}\n").unwrap();
        std::fs::write(
            root.join("engine/leaf.rs"),
            "//! leaf module without symbols\n",
        )
        .unwrap();

        let graph = compile_code(&root);

        assert_contains_edge(&graph, "mod.lib", "mod.calibration");
        assert_contains_edge(&graph, "mod.lib", "mod.engine");
        assert_contains_edge(&graph, "mod.engine", "mod.engine::leaf");
        assert!(
            !graph.edges.iter().any(|edge| {
                edge.relation == eidos_kernel::schema::relation::CONTAINS
                    && edge.from == "mod.lib"
                    && edge.to == "mod.main"
            }),
            "lib.rs and main.rs are separate source roots, not parent/child modules"
        );

        let connected = graph
            .edges
            .iter()
            .flat_map(|edge| [&edge.from, &edge.to])
            .collect::<std::collections::HashSet<_>>();
        let orphan_modules = graph
            .nodes
            .iter()
            .filter(|node| node.kind == eidos_kernel::schema::Kind::Module)
            .filter(|node| !connected.contains(&node.id))
            .map(|node| node.id.as_str())
            .collect::<Vec<_>>();
        assert!(
            orphan_modules.is_empty(),
            "module hierarchy should connect leaf modules, found {orphan_modules:?}"
        );

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn glyph_resolves_split_impl_containers_and_drops_external_traits() {
        let root = unique_dir("glyph-split-impl-containers");
        std::fs::create_dir_all(root.join("engine")).unwrap();
        std::fs::write(root.join("lib.rs"), "pub mod engine;\n").unwrap();
        std::fs::write(
            root.join("engine.rs"),
            "pub mod graph_ops;\npub struct Engine;\npub struct Config;\n",
        )
        .unwrap();
        std::fs::write(
            root.join("engine/graph_ops.rs"),
            "use super::Engine;\nimpl Engine {\n    pub fn callers(&self) {}\n}\nimpl Default for Engine {\n    fn default() -> Self { Engine }\n}\n",
        )
        .unwrap();

        let graph = compile_code(&root);
        assert_contains_edge(
            &graph,
            "type.engine::Engine",
            "fn.engine::graph_ops::Engine::callers",
        );
        assert!(
            !graph
                .edges
                .iter()
                .any(|edge| edge.from == "type.engine::graph_ops::Engine"
                    && edge.relation == eidos_kernel::schema::relation::CONTAINS),
            "split impl containers should be remapped to the emitted real type"
        );
        assert!(
            !graph.edges.iter().any(|edge| edge.to == "trait.Default"),
            "external traits should not remain as dangling graph endpoints"
        );
        assert_no_dangling_edges(&graph);

        std::fs::remove_dir_all(root).ok();
    }

    fn assert_contains_edge(graph: &Graph, from: &str, to: &str) {
        assert!(
            graph.edges.iter().any(|edge| {
                edge.relation == eidos_kernel::schema::relation::CONTAINS
                    && edge.from == from
                    && edge.to == to
                    && edge.basis == eidos_kernel::schema::EdgeBasis::Resolved
            }),
            "missing resolved contains edge {from} -> {to}"
        );
    }

    fn assert_no_dangling_edges(graph: &Graph) {
        let node_ids = graph
            .nodes
            .iter()
            .map(|node| node.id.as_str())
            .collect::<std::collections::HashSet<_>>();
        let dangling = graph
            .edges
            .iter()
            .filter(|edge| {
                !node_ids.contains(edge.from.as_str()) || !node_ids.contains(edge.to.as_str())
            })
            .collect::<Vec<_>>();
        assert!(dangling.is_empty(), "dangling edges: {dangling:#?}");
    }

    fn unique_dir(tag: &str) -> PathBuf {
        static COUNTER: AtomicU32 = AtomicU32::new(0);
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir =
            std::env::temp_dir().join(format!("eidos-sources-{tag}-{}-{n}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }
}
