//! `eidos-source` — the L0 seam.
//!
//! A [`Source`] is anything that can compile a filesystem root into graph nodes and edges with
//! provenance receipts. The [`SourceRegistry`] composes multiple sources into one merged `Graph`.
//!
//! This is the extensibility unlock: future sources (build-system manifests via RIG, CSV, PDF)
//! implement this trait and drop into the registry without the kernel ever knowing they exist.
//!
//! ## The grounding law
//!
//! The registry does **not** blindly trust source output. Every receipt (`file:line` span) is
//! checked against the real file before the node is admitted. A source that fabricates
//! provenance is rejected. This is the hearth's guarantee: the kernel never lies about where
//! an answer came from.
//!
//! ## v0.1 status
//!
//! The trait + registry are defined here. [`eidos_aun`] (markdown docs) and [`eidos_glyph`]
//! (tree-sitter code) implement `Source`. The `exec` escape hatch (a subprocess emitting
//! `{nodes, edges}` JSON) lands post-v0.1.
//!
//! [`eidos_aun`]: ../eidos_aun/index.html
//! [`eidos_glyph`]: ../eidos_glyph/index.html

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

use eidos_kernel::schema::{Edge, EdgeBasis, EdgeEvidence, Graph, Node, Span, relation};
use serde::{Deserialize, Serialize};

/// A diagnostic emitted by a source during compilation (parse failure, malformed frontmatter,
/// etc.). Sources own their own diagnostic shapes; this is the common carrier the registry
/// merges into.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SourceDiagnostic {
    /// Human-readable message.
    pub message: String,
    /// Machine-routing severity: `error`, `warning`, or `info`.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub severity: String,
    /// Stable machine-readable diagnostic kind.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub kind: String,
    /// Optional source file path.
    pub source: Option<String>,
    /// Optional node id this diagnostic pertains to.
    pub node_id: Option<String>,
    /// Optional repair or inspection hint for agents.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub suggested_action: Option<String>,
    /// Optional tool name that can draft a repair.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repair_tool: Option<String>,
}

/// Output produced by a [`Source`] when it compiles a root directory.
///
/// All fields are owned (`Vec`, not iterator) so sources can be composed and the registry can
/// merge their outputs without lifetime gymnastics.
#[derive(Debug, Clone, Default)]
pub struct SourceOutput {
    /// The nodes extracted from this source (symbols, docs, sections).
    pub nodes: Vec<Node>,
    /// The structural edges (calls, references, contains, wikilinks, mentions).
    pub edges: Vec<Edge>,
    /// Author-declared relation targets that may point outside this source's local graph.
    ///
    /// The registry resolves these after all sources are merged, so docs can explicitly depend on
    /// code symbols, agents can reference capabilities, and future sources can cross-link without
    /// knowing about each other.
    pub pending_edges: Vec<PendingEdge>,
    /// Source-specific diagnostics (parse failures, malformed frontmatter, etc.).
    pub diagnostics: Vec<SourceDiagnostic>,
}

/// A source-authored edge whose target must be resolved against the merged graph.
#[derive(Debug, Clone, Default)]
pub struct PendingEdge {
    pub from: String,
    pub target: String,
    pub relation: String,
    pub evidence: String,
}

/// The source trait: a pluggable compiler `files → (nodes, edges, receipts)`.
///
/// Each implementation owns one input type:
/// - `eidos-aun` implements `Source` for markdown docs + skills.
/// - `eidos-glyph` implements `Source` for code (Rust, Python, TypeScript via tree-sitter).
/// - future: a build-system source reads `Cargo.toml`/`CMakeLists.txt` and emits build-graph nodes.
pub trait Source: Send + Sync {
    /// A short codename for this source (e.g. `"aun"`, `"glyph"`). Used in `SourceLayer` tags
    /// and diagnostic messages.
    fn name(&self) -> &str;

    /// Compile `root` into nodes, edges, and diagnostics. Must be deterministic: the same files
    /// always produce the same graph. Receipts (`source_files` + `span`) must point at real files.
    fn compile(&self, root: &Path) -> SourceOutput;

    /// Additional filesystem roots this source reads beyond the `root` the registry holds.
    ///
    /// Most sources compile a single root and need not override this. But a source may fold in
    /// extra roots internally (e.g. Aun's separate docs/codex root). The freshness signature must
    /// walk *every* root a source reads — otherwise a change under an undeclared root is invisible
    /// and the snapshot falsely reports Fresh. Declaring them here closes that hole.
    fn extra_roots(&self) -> Vec<std::path::PathBuf> {
        Vec::new()
    }
}

/// A registered source with its configured root.
pub struct RegisteredSource {
    /// The source implementation.
    pub source: Box<dyn Source>,
    /// The root directory this source compiles from.
    pub root: std::path::PathBuf,
    /// Generated EKF partition provenance for nodes produced by this source root.
    pub partition: Option<String>,
    /// Optional source-local namespace used when multiple roots would otherwise mint the same IDs.
    pub namespace: Option<String>,
}

/// Composes multiple [`Source`]s into one merged `Graph`.
///
/// The registry is how the kernel stays source-agnostic: it asks the registry for a graph, the
/// registry calls each source's `compile`, and the merged output is what retrieval scores over.
pub struct SourceRegistry {
    sources: Vec<RegisteredSource>,
}

impl Default for SourceRegistry {
    fn default() -> Self {
        Self::new()
    }
}

impl SourceRegistry {
    /// Create an empty registry.
    pub fn new() -> Self {
        Self {
            sources: Vec::new(),
        }
    }

    /// Register a source with its root directory.
    pub fn register<S: Source + 'static>(
        &mut self,
        source: S,
        root: impl AsRef<Path>,
    ) -> &mut Self {
        self.sources.push(RegisteredSource {
            source: Box::new(source),
            root: root.as_ref().to_path_buf(),
            partition: None,
            namespace: None,
        });
        self
    }

    /// Register a source with generated EKF partition provenance and optional ID namespace.
    ///
    /// `namespace` is applied before receipt validation and edge resolution. It is intentionally a
    /// registry concern: source extractors keep producing their native local IDs, while the package
    /// boundary decides when global graph IDs need disambiguation.
    pub fn register_partitioned<S: Source + 'static>(
        &mut self,
        source: S,
        root: impl AsRef<Path>,
        partition: Option<String>,
        namespace: Option<String>,
    ) -> &mut Self {
        self.sources.push(RegisteredSource {
            source: Box::new(source),
            root: root.as_ref().to_path_buf(),
            partition,
            namespace,
        });
        self
    }

    /// Compile all registered sources and merge their validated outputs into one `Graph`.
    ///
    /// Nodes with fabricated or impossible receipts are rejected before merge; edges attached to
    /// rejected nodes are dropped. Diagnostics are merged. The result is what Ryn persists and the
    /// kernel scores.
    pub fn compile_all(&self) -> (Graph, Vec<SourceDiagnostic>) {
        let mut nodes = Vec::new();
        let mut edges = Vec::new();
        let mut pending_edges = Vec::new();
        let mut diagnostics = Vec::new();

        for registered in &self.sources {
            let mut out = registered.source.compile(&registered.root);
            apply_source_provenance(
                &mut out,
                registered.partition.as_deref(),
                registered.namespace.as_deref(),
            );
            let mut rejected_ids = HashSet::new();
            for node in out.nodes {
                match validate_node_receipts(&node) {
                    Ok(()) => {
                        nodes.push(node);
                    }
                    Err(message) => {
                        let node_id = node.id.clone();
                        rejected_ids.insert(node_id.clone());
                        diagnostics.push(SourceDiagnostic {
                            message,
                            severity: "error".into(),
                            kind: "invalid_receipt".into(),
                            source: node
                                .span
                                .as_ref()
                                .map(|span| span.path.clone())
                                .or_else(|| node.source_files.first().cloned()),
                            node_id: Some(node_id),
                            suggested_action: Some(
                                "Fix the source extractor receipt so it points at a real file span."
                                    .into(),
                            ),
                            repair_tool: None,
                        });
                    }
                }
            }
            edges.extend(out.edges.into_iter().filter(|edge| {
                !rejected_ids.contains(&edge.from) && !rejected_ids.contains(&edge.to)
            }));
            pending_edges.extend(
                out.pending_edges
                    .into_iter()
                    .filter(|edge| !rejected_ids.contains(&edge.from)),
            );
            diagnostics.extend(out.diagnostics);
        }

        reject_duplicate_node_ids(&mut nodes, &mut edges, &mut pending_edges, &mut diagnostics);

        let node_by_id = nodes
            .iter()
            .map(|node| (node.id.as_str(), node))
            .collect::<std::collections::HashMap<_, _>>();
        let node_ids = nodes
            .iter()
            .map(|node| node.id.as_str())
            .collect::<HashSet<_>>();
        for edge in pending_edges
            .into_iter()
            .filter(|edge| node_ids.contains(edge.from.as_str()))
        {
            match resolve_target(&edge.target, &nodes) {
                TargetResolution::Resolved(to) => {
                    edges.push(Edge {
                        from: edge.from,
                        to,
                        relation: edge.relation,
                        evidence: edge.evidence,
                        // A pending edge is an AUTHORED declaration (frontmatter relation,
                        // agent.toml `uses`/`describes`) whose target was resolved against the
                        // merged graph. The authoring is the evidence — earned Resolved.
                        basis: EdgeBasis::Resolved,
                        ..Default::default()
                    });
                }
                TargetResolution::Unresolved => diagnostics.push(pending_edge_diagnostic(
                    &edge,
                    node_by_id.get(edge.from.as_str()).copied(),
                    "does not resolve to any compiled node",
                )),
                TargetResolution::Ambiguous(matches) => diagnostics.push(pending_edge_diagnostic(
                    &edge,
                    node_by_id.get(edge.from.as_str()).copied(),
                    &format!(
                        "is ambiguous across {} compiled nodes: {}",
                        matches.len(),
                        matches.join(", ")
                    ),
                )),
            }
        }

        enrich_edge_evidence(&mut edges, &node_by_id);

        // Deterministic order: sort by node id so the graph is stable across rebuilds.
        nodes.sort_by(|a, b| a.id.cmp(&b.id));
        merge_duplicate_edges(&mut edges);

        (
            Graph {
                nodes,
                edges,
                ..Default::default()
            },
            diagnostics,
        )
    }

    /// Iterator over registered sources (for diagnostics/reporting).
    pub fn sources(&self) -> impl Iterator<Item = &RegisteredSource> {
        self.sources.iter()
    }
}

fn apply_source_provenance(
    out: &mut SourceOutput,
    partition: Option<&str>,
    namespace: Option<&str>,
) {
    let mut id_map = BTreeMap::new();
    let namespace = namespace
        .map(namespace_slug)
        .filter(|slug| !slug.is_empty());

    for node in &mut out.nodes {
        if let Some(partition) = partition {
            node.partition = Some(partition.to_string());
        }
        if let Some(namespace) = namespace.as_deref() {
            let old_id = node.id.clone();
            let new_id = namespace_node_id(&old_id, namespace);
            if old_id != new_id {
                node.id = new_id.clone();
                id_map.insert(old_id, new_id);
            }
        }
    }

    if id_map.is_empty() {
        return;
    }

    for edge in &mut out.edges {
        if let Some(mapped) = id_map.get(&edge.from) {
            edge.from.clone_from(mapped);
        }
        if let Some(mapped) = id_map.get(&edge.to) {
            edge.to.clone_from(mapped);
        }
    }
    for edge in &mut out.pending_edges {
        if let Some(mapped) = id_map.get(&edge.from) {
            edge.from.clone_from(mapped);
        }
        if let Some(mapped) = id_map.get(&edge.target) {
            edge.target.clone_from(mapped);
        }
    }
    for diagnostic in &mut out.diagnostics {
        if let Some(node_id) = diagnostic.node_id.as_mut()
            && let Some(mapped) = id_map.get(node_id)
        {
            node_id.clone_from(mapped);
        }
    }
}

fn namespace_slug(value: &str) -> String {
    slugify(value)
}

fn namespace_node_id(id: &str, namespace: &str) -> String {
    let (base, suffix) = id.split_once('#').unwrap_or((id, ""));
    let namespaced_base = if let Some(rest) = base.strip_prefix("doc.") {
        format!("doc.{namespace}-{rest}")
    } else if let Some(rest) = base.strip_prefix("skill.") {
        format!("skill.{namespace}-{rest}")
    } else if let Some(rest) = base.strip_prefix("agent.") {
        format!("agent.{namespace}-{rest}")
    } else if let Some(rest) = base.strip_prefix("fn.") {
        format!("fn.{namespace}::{rest}")
    } else if let Some(rest) = base.strip_prefix("type.") {
        format!("type.{namespace}::{rest}")
    } else if let Some(rest) = base.strip_prefix("trait.") {
        format!("trait.{namespace}::{rest}")
    } else if let Some(rest) = base.strip_prefix("mod.") {
        format!("mod.{namespace}::{rest}")
    } else {
        format!("{namespace}::{base}")
    };
    if suffix.is_empty() {
        namespaced_base
    } else {
        format!("{namespaced_base}#{suffix}")
    }
}

fn reject_duplicate_node_ids(
    nodes: &mut Vec<Node>,
    edges: &mut Vec<Edge>,
    pending_edges: &mut Vec<PendingEdge>,
    diagnostics: &mut Vec<SourceDiagnostic>,
) {
    let mut by_id: BTreeMap<String, Vec<Node>> = BTreeMap::new();
    for node in nodes.iter() {
        by_id.entry(node.id.clone()).or_default().push(node.clone());
    }

    let duplicate_ids = by_id
        .iter()
        .filter_map(|(id, nodes)| (nodes.len() > 1).then_some(id.clone()))
        .collect::<HashSet<_>>();
    if duplicate_ids.is_empty() {
        return;
    }

    for (id, colliding_nodes) in by_id.iter().filter(|(_, nodes)| nodes.len() > 1) {
        let sources = colliding_nodes
            .iter()
            .filter_map(node_source)
            .collect::<Vec<_>>();
        diagnostics.push(SourceDiagnostic {
            message: format!(
                "rejected duplicate node id `{}` across {} compiled nodes{}",
                id,
                colliding_nodes.len(),
                if sources.is_empty() {
                    String::new()
                } else {
                    format!(": {}", sources.join(", "))
                }
            ),
            severity: "error".into(),
            kind: "duplicate_node_id".into(),
            source: sources.first().cloned(),
            node_id: Some((*id).clone()),
            suggested_action: Some(
                "Make source roots or partition namespaces produce globally unique node ids."
                    .into(),
            ),
            repair_tool: None,
        });
    }

    nodes.retain(|node| !duplicate_ids.contains(&node.id));
    edges.retain(|edge| !duplicate_ids.contains(&edge.from) && !duplicate_ids.contains(&edge.to));
    pending_edges.retain(|edge| !duplicate_ids.contains(&edge.from));
}

fn enrich_edge_evidence(edges: &mut [Edge], node_by_id: &std::collections::HashMap<&str, &Node>) {
    for edge in edges {
        if edge.evidence_refs.is_empty() {
            edge.evidence_refs.push(edge_evidence(edge, node_by_id));
        }
    }
}

fn edge_evidence(edge: &Edge, node_by_id: &std::collections::HashMap<&str, &Node>) -> EdgeEvidence {
    let primary_id = if edge.relation == relation::CONTAINS {
        edge.to.as_str()
    } else {
        edge.from.as_str()
    };
    let fallback_id = if edge.relation == relation::CONTAINS {
        edge.from.as_str()
    } else {
        edge.to.as_str()
    };
    let (source, span) = node_by_id
        .get(primary_id)
        .and_then(|node| node_receipt(node))
        .or_else(|| {
            node_by_id
                .get(fallback_id)
                .and_then(|node| node_receipt(node))
        })
        .unwrap_or((None, None));
    EdgeEvidence {
        reason: edge.evidence.clone(),
        source,
        span,
    }
}

fn node_receipt(node: &Node) -> Option<(Option<String>, Option<Span>)> {
    if let Some(span) = &node.span {
        return Some((Some(span.path.clone()), Some(span.clone())));
    }
    node.source_files
        .first()
        .map(|source| (Some(source.clone()), None))
}

fn merge_duplicate_edges(edges: &mut Vec<Edge>) {
    edges.sort_by(|a, b| (&a.from, &a.to, &a.relation).cmp(&(&b.from, &b.to, &b.relation)));
    let mut merged: Vec<Edge> = Vec::with_capacity(edges.len());
    for edge in edges.drain(..) {
        if let Some(last) = merged.last_mut()
            && last.from == edge.from
            && last.to == edge.to
            && last.relation == edge.relation
        {
            merge_edge_evidence(last, edge);
            continue;
        }
        merged.push(edge);
    }
    *edges = merged;
}

fn merge_edge_evidence(target: &mut Edge, source: Edge) {
    target.evidence = merge_evidence_summary(&target.evidence, &source.evidence);
    if source.basis == EdgeBasis::Resolved {
        target.basis = EdgeBasis::Resolved;
    }
    if target.asserted_at == 0
        || (source.asserted_at != 0 && source.asserted_at < target.asserted_at)
    {
        target.asserted_at = source.asserted_at;
    }
    target.expires_at = match (target.expires_at, source.expires_at) {
        (Some(a), Some(b)) => Some(a.min(b)),
        (Some(a), None) => Some(a),
        (None, Some(b)) => Some(b),
        (None, None) => None,
    };
    for evidence in source.evidence_refs {
        if !target.evidence_refs.contains(&evidence) {
            target.evidence_refs.push(evidence);
        }
    }
}

fn merge_evidence_summary(a: &str, b: &str) -> String {
    let mut parts = Vec::new();
    for part in a.split(';').chain(b.split(';')) {
        let part = part.trim();
        if !part.is_empty() && !parts.contains(&part) {
            parts.push(part);
        }
    }
    parts.join("; ")
}

enum TargetResolution {
    Resolved(String),
    Unresolved,
    Ambiguous(Vec<String>),
}

fn resolve_target(target: &str, nodes: &[Node]) -> TargetResolution {
    let target = target.trim();
    if let Some(node) = nodes.iter().find(|node| node.id == target) {
        return TargetResolution::Resolved(node.id.clone());
    }

    let slug = slugify(target);
    let mut matches = nodes
        .iter()
        .filter(|node| {
            node.id.rsplit('.').next() == Some(slug.as_str())
                || slugify(&node.title) == slug
                || node.aliases.iter().any(|alias| slugify(alias) == slug)
        })
        .map(|node| node.id.clone())
        .collect::<Vec<_>>();
    matches.sort();
    matches.dedup();
    match matches.len() {
        0 => TargetResolution::Unresolved,
        1 => TargetResolution::Resolved(matches.remove(0)),
        _ => TargetResolution::Ambiguous(matches),
    }
}

fn pending_edge_diagnostic(
    edge: &PendingEdge,
    from: Option<&Node>,
    reason: &str,
) -> SourceDiagnostic {
    SourceDiagnostic {
        message: format!(
            "frontmatter relation `{}` from `{}` to `{}` {reason}",
            edge.relation, edge.from, edge.target
        ),
        severity: "warning".into(),
        kind: "unresolved_relation_target".into(),
        source: from.and_then(node_source),
        node_id: Some(edge.from.clone()),
        suggested_action: Some(format!(
            "Create a node for `{}` or update `{}` to an existing title, id, or alias.",
            edge.target, edge.relation
        )),
        repair_tool: Some("propose".into()),
    }
}

fn node_source(node: &Node) -> Option<String> {
    node.span
        .as_ref()
        .map(|span| span.path.clone())
        .or_else(|| node.source_files.first().cloned())
}

fn slugify(value: &str) -> String {
    let mut out = String::new();
    let mut prev_dash = false;
    for ch in value.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_string()
}

fn validate_node_receipts(node: &Node) -> Result<(), String> {
    if node.source_files.is_empty() && node.span.is_none() {
        return Err("rejected node with no provenance receipt".to_string());
    }

    for source in &node.source_files {
        validate_source_file(source)?;
    }

    if let Some(span) = &node.span {
        validate_source_file(&span.path)?;
        // The span must receipt a file the node CLAIMS as a source — otherwise a node could
        // point its span at any readable file on disk (e.g. /etc/hostname) and pass. Ties the
        // span receipt to the node's declared provenance.
        if !node.source_files.iter().any(|f| f == &span.path) {
            return Err(format!(
                "rejected node '{}' whose span source '{}' is not among its source_files",
                node.id, span.path
            ));
        }
        if span.start_line == 0 {
            return Err(format!(
                "rejected node '{}' with span start_line=0",
                node.id
            ));
        }
        if span.end_line < span.start_line {
            return Err(format!(
                "rejected node '{}' with inverted span {}-{}",
                node.id, span.start_line, span.end_line
            ));
        }
        let raw = std::fs::read_to_string(&span.path).map_err(|e| {
            format!(
                "rejected node '{}' with unreadable span source '{}': {e}",
                node.id, span.path
            )
        })?;
        let line_count = raw.lines().count().max(1);
        if span.end_line > line_count {
            return Err(format!(
                "rejected node '{}' with span ending at line {} beyond file length {}",
                node.id, span.end_line, line_count
            ));
        }
    }

    Ok(())
}

fn validate_source_file(source: &str) -> Result<(), String> {
    if source.trim().is_empty() {
        return Err("rejected node with empty source file receipt".to_string());
    }
    let path = Path::new(source);
    match std::fs::metadata(path) {
        Ok(meta) if meta.is_file() => Ok(()),
        Ok(_) => Err(format!(
            "rejected node with non-file source receipt '{source}'"
        )),
        Err(e) => Err(format!(
            "rejected node with missing source receipt '{source}': {e}"
        )),
    }
}

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

    struct FixtureSource;

    impl Source for FixtureSource {
        fn name(&self) -> &str {
            "fixture"
        }

        fn compile(&self, root: &Path) -> SourceOutput {
            let good = root.join("good.md").to_string_lossy().into_owned();
            let missing = root.join("missing.md").to_string_lossy().into_owned();
            SourceOutput {
                nodes: vec![
                    node("good", vec![good], None),
                    node("bad", vec![missing], None),
                ],
                edges: vec![
                    Edge {
                        from: "bad".into(),
                        to: "good".into(),
                        relation: "mentions".into(),
                        evidence: "bad edge".into(),
                        ..Default::default()
                    },
                    Edge {
                        from: "good".into(),
                        to: "external.target".into(),
                        relation: "describes".into(),
                        evidence: "cross-source target".into(),
                        ..Default::default()
                    },
                ],
                pending_edges: Vec::new(),
                diagnostics: Vec::new(),
            }
        }
    }

    struct BadSpanSource;

    impl Source for BadSpanSource {
        fn name(&self) -> &str {
            "bad-span"
        }

        fn compile(&self, root: &Path) -> SourceOutput {
            let source = root.join("short.rs").to_string_lossy().into_owned();
            SourceOutput {
                nodes: vec![node(
                    "bad-span",
                    vec![source.clone()],
                    Some(Span {
                        path: source,
                        start_line: 1,
                        end_line: 99,
                    }),
                )],
                edges: Vec::new(),
                pending_edges: Vec::new(),
                diagnostics: Vec::new(),
            }
        }
    }

    struct ForeignSpanSource;

    impl Source for ForeignSpanSource {
        fn name(&self) -> &str {
            "foreign-span"
        }

        fn compile(&self, root: &Path) -> SourceOutput {
            // The node lists its own file as a source, but its span points at an UNRELATED
            // real file — the fabricated-receipt attack the D1.3 scoping check must reject.
            let own = root.join("own.rs").to_string_lossy().into_owned();
            let foreign = root.join("foreign.rs").to_string_lossy().into_owned();
            std::fs::write(&own, "fn f() {}\n").unwrap();
            std::fs::write(&foreign, "unrelated\n").unwrap();
            SourceOutput {
                nodes: vec![node(
                    "foreign-span",
                    vec![own],
                    Some(Span {
                        path: foreign,
                        start_line: 1,
                        end_line: 1,
                    }),
                )],
                edges: Vec::new(),
                pending_edges: Vec::new(),
                diagnostics: Vec::new(),
            }
        }
    }

    struct PendingSource {
        node: Node,
        pending_edges: Vec<PendingEdge>,
    }

    impl Source for PendingSource {
        fn name(&self) -> &str {
            "pending"
        }

        fn compile(&self, _root: &Path) -> SourceOutput {
            SourceOutput {
                nodes: vec![self.node.clone()],
                edges: Vec::new(),
                pending_edges: self.pending_edges.clone(),
                diagnostics: Vec::new(),
            }
        }
    }

    struct EdgeSource {
        nodes: Vec<Node>,
        edges: Vec<Edge>,
    }

    impl Source for EdgeSource {
        fn name(&self) -> &str {
            "edge"
        }

        fn compile(&self, _root: &Path) -> SourceOutput {
            SourceOutput {
                nodes: self.nodes.clone(),
                edges: self.edges.clone(),
                pending_edges: Vec::new(),
                diagnostics: Vec::new(),
            }
        }
    }

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

    #[test]
    fn registry_rejects_nodes_with_invalid_receipts() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("good.md"), "ok").unwrap();

        let mut registry = SourceRegistry::new();
        registry.register(FixtureSource, tmp.path());
        let (graph, diagnostics) = registry.compile_all();

        assert_eq!(graph.nodes.len(), 1);
        assert_eq!(graph.nodes[0].id, "good");
        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].node_id.as_deref(), Some("bad"));
        assert!(diagnostics[0].message.contains("missing source receipt"));
    }

    #[test]
    fn registry_rejects_span_pointing_outside_declared_sources() {
        let tmp = tempfile::tempdir().unwrap();
        let mut registry = SourceRegistry::new();
        registry.register(ForeignSpanSource, tmp.path());
        let (graph, diagnostics) = registry.compile_all();

        assert!(
            graph.nodes.is_empty(),
            "a node whose span points outside its source_files must be rejected"
        );
        assert_eq!(diagnostics.len(), 1);
        assert!(
            diagnostics[0]
                .message
                .contains("not among its source_files"),
            "diagnostic should explain the receipt-scope violation: {}",
            diagnostics[0].message
        );
    }

    #[test]
    fn registry_drops_edges_attached_to_rejected_nodes_only() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("good.md"), "ok").unwrap();

        let mut registry = SourceRegistry::new();
        registry.register(FixtureSource, tmp.path());
        let (graph, _diagnostics) = registry.compile_all();

        assert_eq!(graph.edges.len(), 1);
        assert_eq!(graph.edges[0].from, "good");
        assert_eq!(graph.edges[0].to, "external.target");
    }

    #[test]
    fn registry_rejects_impossible_spans() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("short.rs"), "one\n").unwrap();

        let mut registry = SourceRegistry::new();
        registry.register(BadSpanSource, tmp.path());
        let (graph, diagnostics) = registry.compile_all();

        assert!(graph.nodes.is_empty());
        assert_eq!(diagnostics.len(), 1);
        assert!(diagnostics[0].message.contains("beyond file length"));
    }

    #[test]
    fn registry_resolves_pending_edges_after_sources_merge() {
        let tmp = tempfile::tempdir().unwrap();
        let doc_path = tmp.path().join("policy.md");
        let code_path = tmp.path().join("loans.py");
        std::fs::write(&doc_path, "policy").unwrap();
        std::fs::write(&code_path, "def assess_fine(): pass\n").unwrap();

        let doc = node(
            "doc.return-policy",
            vec![doc_path.to_string_lossy().into_owned()],
            None,
        );
        let code = Node {
            kind: Kind::Function,
            title: "assess_fine".into(),
            span: Some(Span {
                path: code_path.to_string_lossy().into_owned(),
                start_line: 1,
                end_line: 1,
            }),
            ..node(
                "fn.loans::LoanPolicy::assess_fine",
                vec![code_path.to_string_lossy().into_owned()],
                None,
            )
        };

        let mut registry = SourceRegistry::new();
        registry.register(
            PendingSource {
                node: doc,
                pending_edges: vec![PendingEdge {
                    from: "doc.return-policy".into(),
                    target: "fn.loans::LoanPolicy::assess_fine".into(),
                    relation: "depends_on".into(),
                    evidence: "frontmatter".into(),
                }],
            },
            tmp.path(),
        );
        registry.register(
            PendingSource {
                node: code,
                pending_edges: Vec::new(),
            },
            tmp.path(),
        );

        let (graph, diagnostics) = registry.compile_all();

        assert!(diagnostics.is_empty());
        assert!(graph.edges.iter().any(|edge| {
            edge.from == "doc.return-policy"
                && edge.to == "fn.loans::LoanPolicy::assess_fine"
                && edge.relation == "depends_on"
        }));
    }

    #[test]
    fn registry_merges_duplicate_edges_without_losing_evidence() {
        let tmp = tempfile::tempdir().unwrap();
        let a_path = tmp.path().join("a.md");
        let b_path = tmp.path().join("b.md");
        std::fs::write(&a_path, "a").unwrap();
        std::fs::write(&b_path, "b").unwrap();

        let a = node("doc.a", vec![a_path.to_string_lossy().into_owned()], None);
        let b = node("doc.b", vec![b_path.to_string_lossy().into_owned()], None);
        let duplicate = |evidence: &str| Edge {
            from: "doc.a".into(),
            to: "doc.b".into(),
            relation: "depends_on".into(),
            evidence: evidence.into(),
            ..Default::default()
        };

        let mut registry = SourceRegistry::new();
        registry.register(
            EdgeSource {
                nodes: vec![a, b],
                edges: vec![duplicate("frontmatter"), duplicate("wikilink")],
            },
            tmp.path(),
        );

        let (graph, diagnostics) = registry.compile_all();

        assert!(diagnostics.is_empty());
        assert_eq!(graph.edges.len(), 1);
        let edge = &graph.edges[0];
        assert_eq!(edge.evidence, "frontmatter; wikilink");
        assert_eq!(edge.evidence_refs.len(), 2);
        assert!(
            edge.evidence_refs
                .iter()
                .any(|evidence| evidence.reason == "frontmatter")
        );
        assert!(
            edge.evidence_refs
                .iter()
                .any(|evidence| evidence.reason == "wikilink")
        );
        assert!(
            edge.evidence_refs
                .iter()
                .all(|evidence| evidence.source.as_deref() == Some(a_path.to_str().unwrap()))
        );
    }

    #[test]
    fn registry_rejects_duplicate_node_ids_fail_closed() {
        let tmp = tempfile::tempdir().unwrap();
        let first_path = tmp.path().join("first.md");
        let second_path = tmp.path().join("second.md");
        let target_path = tmp.path().join("target.md");
        std::fs::write(&first_path, "first").unwrap();
        std::fs::write(&second_path, "second").unwrap();
        std::fs::write(&target_path, "target").unwrap();

        let first = node(
            "doc.shared",
            vec![first_path.to_string_lossy().into_owned()],
            None,
        );
        let second = node(
            "doc.shared",
            vec![second_path.to_string_lossy().into_owned()],
            None,
        );
        let target = node(
            "doc.target",
            vec![target_path.to_string_lossy().into_owned()],
            None,
        );

        let mut registry = SourceRegistry::new();
        registry.register(
            EdgeSource {
                nodes: vec![first, second, target],
                edges: vec![Edge {
                    from: "doc.shared".into(),
                    to: "doc.target".into(),
                    relation: "depends_on".into(),
                    evidence: "duplicate source".into(),
                    ..Default::default()
                }],
            },
            tmp.path(),
        );

        let (graph, diagnostics) = registry.compile_all();

        assert_eq!(graph.nodes.len(), 1);
        assert_eq!(graph.nodes[0].id, "doc.target");
        assert!(graph.edges.is_empty());
        assert!(diagnostics.iter().any(|diagnostic| {
            diagnostic.kind == "duplicate_node_id"
                && diagnostic.severity == "error"
                && diagnostic.node_id.as_deref() == Some("doc.shared")
                && diagnostic.message.contains("first.md")
                && diagnostic.message.contains("second.md")
        }));
    }

    #[test]
    fn registry_enriches_contains_edges_with_child_receipts() {
        let tmp = tempfile::tempdir().unwrap();
        let parent_path = tmp.path().join("parent.rs");
        let child_path = tmp.path().join("child.rs");
        std::fs::write(&parent_path, "parent\nparent body\n").unwrap();
        std::fs::write(&child_path, "child\nchild body\n").unwrap();

        let parent_source = parent_path.to_string_lossy().into_owned();
        let child_source = child_path.to_string_lossy().into_owned();
        let parent = node(
            "type.Parent",
            vec![parent_source.clone()],
            Some(Span {
                path: parent_source,
                start_line: 1,
                end_line: 2,
            }),
        );
        let child = node(
            "fn.Parent::child",
            vec![child_source.clone()],
            Some(Span {
                path: child_source.clone(),
                start_line: 2,
                end_line: 2,
            }),
        );

        let mut registry = SourceRegistry::new();
        registry.register(
            EdgeSource {
                nodes: vec![parent, child],
                edges: vec![Edge {
                    from: "type.Parent".into(),
                    to: "fn.Parent::child".into(),
                    relation: relation::CONTAINS.into(),
                    evidence: "child declaration".into(),
                    ..Default::default()
                }],
            },
            tmp.path(),
        );

        let (graph, diagnostics) = registry.compile_all();

        assert!(diagnostics.is_empty());
        let evidence = &graph.edges[0].evidence_refs[0];
        assert_eq!(evidence.source.as_deref(), Some(child_source.as_str()));
        assert_eq!(evidence.span.as_ref().map(|span| span.start_line), Some(2));
    }

    #[test]
    fn registry_reports_unresolved_pending_edges() {
        let tmp = tempfile::tempdir().unwrap();
        let doc_path = tmp.path().join("policy.md");
        std::fs::write(&doc_path, "policy").unwrap();

        let mut registry = SourceRegistry::new();
        registry.register(
            PendingSource {
                node: node(
                    "doc.return-policy",
                    vec![doc_path.to_string_lossy().into_owned()],
                    None,
                ),
                pending_edges: vec![PendingEdge {
                    from: "doc.return-policy".into(),
                    target: "missing-symbol".into(),
                    relation: "depends_on".into(),
                    evidence: "frontmatter".into(),
                }],
            },
            tmp.path(),
        );

        let (graph, diagnostics) = registry.compile_all();

        assert!(graph.edges.is_empty());
        assert!(diagnostics.iter().any(|diagnostic| {
            diagnostic.node_id.as_deref() == Some("doc.return-policy")
                && diagnostic
                    .message
                    .contains("does not resolve to any compiled node")
                && diagnostic.source.as_deref() == Some(doc_path.to_str().unwrap())
        }));
    }

    #[test]
    fn registry_reports_ambiguous_pending_edges() {
        let tmp = tempfile::tempdir().unwrap();
        let doc_path = tmp.path().join("policy.md");
        let first_path = tmp.path().join("first.md");
        let second_path = tmp.path().join("second.md");
        std::fs::write(&doc_path, "policy").unwrap();
        std::fs::write(&first_path, "target").unwrap();
        std::fs::write(&second_path, "target").unwrap();

        let first = Node {
            title: "Shared Target".into(),
            ..node(
                "doc.first-target",
                vec![first_path.to_string_lossy().into_owned()],
                None,
            )
        };
        let second = Node {
            title: "Shared Target".into(),
            ..node(
                "doc.second-target",
                vec![second_path.to_string_lossy().into_owned()],
                None,
            )
        };

        let mut registry = SourceRegistry::new();
        registry.register(
            PendingSource {
                node: node(
                    "doc.return-policy",
                    vec![doc_path.to_string_lossy().into_owned()],
                    None,
                ),
                pending_edges: vec![PendingEdge {
                    from: "doc.return-policy".into(),
                    target: "Shared Target".into(),
                    relation: "related_to".into(),
                    evidence: "frontmatter".into(),
                }],
            },
            tmp.path(),
        );
        registry.register(
            PendingSource {
                node: first,
                pending_edges: Vec::new(),
            },
            tmp.path(),
        );
        registry.register(
            PendingSource {
                node: second,
                pending_edges: Vec::new(),
            },
            tmp.path(),
        );

        let (graph, diagnostics) = registry.compile_all();

        assert!(graph.edges.is_empty());
        assert!(diagnostics.iter().any(|diagnostic| {
            diagnostic
                .message
                .contains("is ambiguous across 2 compiled nodes")
                && diagnostic.message.contains("doc.first-target")
                && diagnostic.message.contains("doc.second-target")
        }));
    }
}
