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

use eidos_kernel::schema::relation::{CONTAINS, IMPLEMENTS, REFERENCES};
use eidos_kernel::schema::{Edge, EdgeBasis as CoreEdgeBasis, Kind, Node};
use serde::Serialize;

use crate::glyph::extract;
use crate::glyph::graph::RefName;
use crate::glyph::walk::walk_code;

/// Max distinct `references` edges emitted from a single source symbol — mirrors the docs
/// builder's untrusted `mentions` cap, keeping the candidate class bounded.
const REFERENCES_CAP: usize = 25;

/// The provenance band of a code edge — the H0d/S3 grounding-honesty label. An
/// analyzer reads this to know whether an edge is trustworthy: only `resolved`
/// edges may back a Strong (claim-eligible) finding; `lexical`/`unresolved` ones
/// keep a finding weak. Glyph also writes the compatible core `EdgeBasis` onto graph edges.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EdgeBasis {
    /// Statically proven target: containment, a matched local trait impl, or a
    /// reference whose name is globally unique (one possible definition).
    #[default]
    Resolved,
    /// Candidate by name match, not proven — a globally-ambiguous reference
    /// disambiguated only by sharing the source's module (a heuristic).
    Lexical,
    /// Known unknown: an ambiguous reference we can't pin to one target, or an impl
    /// of a trait defined outside the compile (std/foreign) — the honest "we see a
    /// site but can't prove its target" class (dyn dispatch, macros, FFI later).
    Unresolved,
}

/// A code edge carrying its [`EdgeBasis`] — the provenance-banded view Scry's
/// edge-dependent analyzers (coupling, dead_code) consume. Mirrors a core `Edge`
/// plus `basis`.
#[derive(Debug, Clone, PartialEq, Serialize, Default)]
pub struct ProvEdge {
    pub from: String,
    pub to: String,
    pub relation: String,
    pub basis: EdgeBasis,
    pub evidence: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReferenceBasis {
    Structural,
    GlobalUnique,
    ImportAware,
    /// A `self.<attr>.<method>()` call disambiguated by the inferred receiver type — the method is
    /// proven to be the one on that exact type, so it earns `Resolved`.
    ReceiverResolved,
    LocalLexical,
    UnresolvedAmbiguous,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ReferenceEvidence {
    pub source_node_id: String,
    pub target_node_id: String,
    pub relation: String,
    pub basis: ReferenceBasis,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_file: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_start_line: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_end_line: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub imported_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub imported_module: Option<String>,
}

/// Resolve each item's collected identifiers into `references` edges. A name resolves to a target
/// when it's unique across the compile, or — if ambiguous — when exactly one candidate shares the
/// source's module (don't guess otherwise; references is the untrusted/candidate class). Self
/// references are dropped, edges are deduped per `(from, to)`, and capped per source.
pub(crate) fn build_references(
    nodes: &[Node],
    file_refs: &[(String, Vec<RefName>)],
    edges: &mut Vec<Edge>,
) {
    let mut emitted_by_source: HashMap<String, HashSet<String>> = HashMap::new();
    for evidence in reference_evidence(nodes, file_refs)
        .into_iter()
        .filter(|evidence| evidence.relation == REFERENCES)
        .filter(|evidence| evidence.basis != ReferenceBasis::UnresolvedAmbiguous)
    {
        if evidence.source_node_id == evidence.target_node_id {
            continue;
        }
        let emitted = emitted_by_source
            .entry(evidence.source_node_id.clone())
            .or_default();
        if emitted.len() >= REFERENCES_CAP || !emitted.insert(evidence.target_node_id.clone()) {
            continue;
        }
        // A globally-unique NAME match earns `Resolved` (one possible definition in the compile).
        // NOTE: an earlier revision downgraded cross-"crate" GlobalUnique to Lexical to kill a
        // ProjectZone::Cli-style name collision, but `crate_of` (first id segment) cannot tell a real
        // crate boundary from a mere MODULE boundary — a single-crate corpus ids as `fn.<module>::…`,
        // so intra-crate cross-module calls (scheduler→policy) were wrongly downgraded, breaking the
        // se-corpus trusted-edge gate and Python cross-module resolution. Reverted; the residual
        // ProjectZone::Cli over-report is tracked as debt (its real fix is scope-aware local exclusion).
        let basis = match evidence.basis {
            ReferenceBasis::ImportAware
            | ReferenceBasis::GlobalUnique
            | ReferenceBasis::ReceiverResolved => CoreEdgeBasis::Resolved,
            ReferenceBasis::LocalLexical
            | ReferenceBasis::Structural
            | ReferenceBasis::UnresolvedAmbiguous => CoreEdgeBasis::Lexical,
        };
        let evidence_str = match evidence.basis {
            ReferenceBasis::ImportAware => format!(
                "import-aware lexical reference via {}",
                evidence.imported_module.as_deref().unwrap_or("import")
            ),
            ReferenceBasis::ReceiverResolved => {
                eidos_kernel::schema::TRAVERSAL_ONLY_EVIDENCE.to_string()
            }
            ReferenceBasis::GlobalUnique => "global-unique lexical reference".into(),
            ReferenceBasis::LocalLexical => "local lexical reference".into(),
            ReferenceBasis::Structural | ReferenceBasis::UnresolvedAmbiguous => {
                "lexical reference".into()
            }
        };
        edges.push(Edge {
            from: evidence.source_node_id,
            to: evidence.target_node_id,
            relation: REFERENCES.into(),
            evidence: evidence_str,
            basis,
            ..Default::default()
        });
    }
}

pub(crate) fn structural_evidence(edges: &[Edge]) -> Vec<ReferenceEvidence> {
    edges
        .iter()
        .filter(|edge| matches!(edge.relation.as_str(), CONTAINS | IMPLEMENTS))
        .map(|edge| ReferenceEvidence {
            source_node_id: edge.from.clone(),
            target_node_id: edge.to.clone(),
            relation: edge.relation.clone(),
            basis: ReferenceBasis::Structural,
            source_file: None,
            source_start_line: None,
            source_end_line: None,
            imported_name: None,
            imported_module: None,
        })
        .collect()
}

pub(crate) fn reference_evidence(
    nodes: &[Node],
    file_refs: &[(String, Vec<RefName>)],
) -> Vec<ReferenceEvidence> {
    let mut by_name: HashMap<&str, Vec<&Node>> = HashMap::new();
    for n in nodes
        .iter()
        .filter(|n| matches!(n.kind, Kind::Function | Kind::Type | Kind::Trait))
    {
        by_name.entry(n.title.as_str()).or_default().push(n);
    }
    let by_id: HashMap<&str, &Node> = nodes.iter().map(|node| (node.id.as_str(), node)).collect();
    let mut out = Vec::new();
    for (source_id, idents) in file_refs {
        let source_mod = modpath_of(source_id);
        let source = by_id.get(source_id.as_str()).copied();
        let mut emitted: HashSet<(String, String, Option<String>)> = HashSet::new();
        for ident in idents {
            if emitted.len() >= REFERENCES_CAP {
                break;
            }
            let Some(cands) = by_name.get(ident.name.as_str()) else {
                continue;
            };
            for (target, basis) in classify_reference_evidence(
                cands,
                source_mod,
                ident.imported_mod.as_deref(),
                ident.receiver_type.as_deref(),
            ) {
                if target == *source_id {
                    continue;
                }
                let key = (
                    target.clone(),
                    format!("{basis:?}"),
                    ident.imported_mod.clone(),
                );
                if !emitted.insert(key) {
                    continue;
                }
                out.push(ReferenceEvidence {
                    source_node_id: source_id.clone(),
                    target_node_id: target,
                    relation: REFERENCES.into(),
                    basis,
                    source_file: source
                        .and_then(|node| node.span.as_ref())
                        .map(|span| span.path.clone())
                        .or_else(|| source.and_then(|node| node.source_files.first().cloned())),
                    source_start_line: source
                        .and_then(|node| node.span.as_ref())
                        .map(|span| span.start_line),
                    source_end_line: source
                        .and_then(|node| node.span.as_ref())
                        .map(|span| span.end_line),
                    imported_name: Some(ident.name.clone()),
                    imported_module: ident.imported_mod.clone(),
                });
            }
        }
    }
    out.sort_by(|a, b| {
        (
            &a.source_node_id,
            &a.target_node_id,
            &a.relation,
            format!("{:?}", a.basis),
            &a.imported_name,
            &a.imported_module,
        )
            .cmp(&(
                &b.source_node_id,
                &b.target_node_id,
                &b.relation,
                format!("{:?}", b.basis),
                &b.imported_name,
                &b.imported_module,
            ))
    });
    out
}

fn resolve_imported_reference<'a>(cands: &[&'a Node], imported_mod: &str) -> Option<&'a str> {
    let mut matches = cands
        .iter()
        .filter(|n| mod_matches_import(modpath_of(&n.id), imported_mod));
    let first = matches.next()?;
    match matches.next() {
        None => Some(first.id.as_str()),
        Some(_) => None,
    }
}

fn mod_matches_import(candidate_mod: &str, imported_mod: &str) -> bool {
    // Import paths use the crate-root aliases `crate::`/`self::`/`super::`, while the compiled
    // modpath is relative to the code root (often prefixed by the source dir, e.g. `src::`).
    // Strip the aliases so `crate::uniq` matches a compiled `src::uniq` (or bare `uniq`).
    let imported_mod = strip_crate_root(imported_mod);
    let candidate_mod = strip_crate_root(candidate_mod);
    // Match when either module path is a `::`-boundary-aligned suffix of the other — import paths
    // are often fully qualified while the compiled modpath is the file-relative tail.
    candidate_mod == imported_mod
        || is_mod_suffix(candidate_mod, imported_mod)
        || is_mod_suffix(imported_mod, candidate_mod)
}

/// Strip leading crate-root aliases (`crate::`, `self::`, `super::`) so import paths compare
/// against code-root-relative modpaths.
fn strip_crate_root(mut m: &str) -> &str {
    loop {
        m = match m.split_once("::") {
            Some(("crate" | "self" | "super", rest)) => rest,
            _ => return m,
        };
    }
}

/// True if `needle` is `whole` or a `::`-boundary-aligned suffix of `whole`.
fn is_mod_suffix(whole: &str, needle: &str) -> bool {
    whole
        .strip_suffix(needle)
        .is_some_and(|prefix| prefix.is_empty() || prefix.ends_with("::"))
}

/// The module path embedded in a node id (`fn.retrieval::ground` → `retrieval`, `a::b::n` → `a::b`).
fn modpath_of(id: &str) -> &str {
    let after_prefix = id.split_once('.').map_or(id, |x| x.1);
    after_prefix.rsplit_once("::").map_or(after_prefix, |x| x.0)
}

/// The enclosing type/symbol of a member id: the `::`-segment just before the final name
/// (`fn.mod::Memory::append` → `Memory`). `None` for a top-level symbol with no enclosing segment.
fn enclosing_type_of(id: &str) -> Option<&str> {
    let mut parts = id.rsplit("::");
    parts.next()?; // the member name
    parts.next() // the enclosing type
}

/// Rewrite each `implements` edge's target from the external marker `trait.<Name>` to a real
/// Trait node id when one with that title exists in the compile (a local trait). Edges to traits
/// defined elsewhere (e.g. std's `From`) keep the external marker — honest about what we can see.
pub(crate) fn resolve_implements(nodes: &[Node], edges: &mut [Edge]) {
    let trait_id_by_title: HashMap<&str, &str> = nodes
        .iter()
        .filter(|n| n.kind == Kind::Trait)
        .map(|n| (n.title.as_str(), n.id.as_str()))
        .collect();
    for e in edges.iter_mut().filter(|e| e.relation == IMPLEMENTS) {
        if let Some(name) = e.to.strip_prefix("trait.")
            && let Some(id) = trait_id_by_title.get(name)
        {
            e.to = id.to_string();
        }
    }
}

/// Compile `root` and return every code edge **banded with its provenance**
/// ([`EdgeBasis`]) — the S3 resolver. `contains` and matched-local `implements`
/// edges are `resolved`; an `implements` of a trait outside the compile is
/// `unresolved`; a `references` edge is `resolved` (globally-unique name),
/// `lexical` (disambiguated only by module), or `unresolved` (ambiguous — emitted
/// to every candidate so the unresolved site is visible, not silently dropped).
/// This is the gate for honest, claim-eligible coupling/dead_code analyzers.
pub fn resolve_edges(root: &Path) -> Vec<ProvEdge> {
    let mut files = Vec::new();
    let mut ignored = BTreeMap::new();
    let mut ignored_files = Vec::new();
    let mut excluded_paths = Vec::new();
    walk_code(
        root,
        &mut files,
        &mut ignored,
        &mut ignored_files,
        &mut excluded_paths,
        &crate::glyph::walk::CodeWalkOptions::default(),
    );
    files.sort_by(|a, b| a.0.cmp(&b.0));

    let mut nodes = Vec::new();
    let mut structural = Vec::new(); // contains / implements (from syntax)
    let mut file_refs: Vec<(String, Vec<RefName>)> = Vec::new();
    for (path, meta) in &files {
        let fg = extract::symbols_in_file_with_language(path, root, meta.language);
        nodes.extend(fg.nodes);
        structural.extend(fg.edges);
        file_refs.extend(fg.refs);
    }
    resolve_implements(&nodes, &mut structural);
    let node_ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();

    let mut out: Vec<ProvEdge> = Vec::new();

    // Structural edges: containment is always proven; an implements edge is resolved
    // iff its (post-rewrite) target is a trait node we actually compiled — otherwise
    // the trait is foreign (std/external) and the impl target is a known unknown.
    for e in &structural {
        let basis = if e.relation == CONTAINS {
            EdgeBasis::Resolved
        } else if e.relation == IMPLEMENTS {
            if node_ids.contains(e.to.as_str()) {
                EdgeBasis::Resolved
            } else {
                EdgeBasis::Unresolved
            }
        } else {
            EdgeBasis::Lexical
        };
        out.push(ProvEdge {
            from: e.from.clone(),
            to: e.to.clone(),
            relation: e.relation.clone(),
            basis,
            evidence: e.evidence.clone(),
        });
    }

    // References: classify each body-identifier against the symbol table.
    let mut by_name: HashMap<&str, Vec<&Node>> = HashMap::new();
    for n in nodes
        .iter()
        .filter(|n| matches!(n.kind, Kind::Function | Kind::Type | Kind::Trait))
    {
        by_name.entry(n.title.as_str()).or_default().push(n);
    }
    for (source_id, idents) in &file_refs {
        let source_mod = modpath_of(source_id);
        let mut emitted: HashSet<String> = HashSet::new();
        for ident in idents {
            if emitted.len() >= REFERENCES_CAP {
                break;
            }
            let Some(cands) = by_name.get(ident.name.as_str()) else {
                continue;
            };
            for (target, basis) in
                classify_reference(cands, source_mod, ident.imported_mod.as_deref())
            {
                if target == *source_id || !emitted.insert(target.clone()) {
                    continue;
                }
                out.push(ProvEdge {
                    from: source_id.clone(),
                    to: target,
                    relation: REFERENCES.into(),
                    basis,
                    evidence: format!("{} reference in {source_id}", basis_label(basis)),
                });
            }
        }
    }

    out.sort_by(|a, b| (&a.from, &a.to, &a.relation).cmp(&(&b.from, &b.to, &b.relation)));
    out.dedup_by(|a, b| a.from == b.from && a.to == b.to && a.relation == b.relation);
    out
}

/// Classify a referenced name against its candidate definitions: a sole candidate is
/// `resolved`; a globally-ambiguous name with exactly one same-module candidate is
/// `lexical` (heuristic); otherwise it's `unresolved` against EVERY candidate, so the
/// unresolved site stays visible instead of being silently dropped.
fn classify_reference(
    cands: &[&Node],
    source_mod: &str,
    imported_mod: Option<&str>,
) -> Vec<(String, EdgeBasis)> {
    if let Some(imported_mod) = imported_mod
        && let Some(target) = resolve_imported_reference(cands, imported_mod)
    {
        return vec![(target.to_string(), EdgeBasis::Resolved)];
    }
    if cands.len() == 1 {
        // A globally-unique name has exactly one definition, so the reference is unambiguous —
        // Resolved. (An import-backed ref already returned above with stronger ImportAware
        // evidence; this covers same-crate references and method calls, which are never
        // `use`-imported.)
        return vec![(cands[0].id.clone(), EdgeBasis::Resolved)];
    }
    let same: Vec<&&Node> = cands
        .iter()
        .filter(|n| modpath_of(&n.id) == source_mod)
        .collect();
    if same.len() == 1 {
        return vec![(same[0].id.clone(), EdgeBasis::Lexical)];
    }
    cands
        .iter()
        .map(|n| (n.id.clone(), EdgeBasis::Unresolved))
        .collect()
}

fn classify_reference_evidence(
    cands: &[&Node],
    source_mod: &str,
    imported_mod: Option<&str>,
    receiver_type: Option<&str>,
) -> Vec<(String, ReferenceBasis)> {
    // Receiver-typed method call: keep only the candidate enclosed by the inferred type. If exactly
    // one matches, that IS the target — proven by structure, so it resolves even when the bare name
    // is ambiguous across classes (the case a name-only match would drop as UnresolvedAmbiguous).
    if let Some(rt) = receiver_type {
        let matches: Vec<&&Node> = cands
            .iter()
            .filter(|n| enclosing_type_of(&n.id) == Some(rt))
            .collect();
        if matches.len() == 1 {
            return vec![(matches[0].id.clone(), ReferenceBasis::ReceiverResolved)];
        }
        // 0 or >1 → can't disambiguate; fall through to the name-based logic.
    }
    if let Some(imported_mod) = imported_mod
        && let Some(target) = resolve_imported_reference(cands, imported_mod)
    {
        return vec![(target.to_string(), ReferenceBasis::ImportAware)];
    }
    if cands.len() == 1 {
        return vec![(cands[0].id.clone(), ReferenceBasis::GlobalUnique)];
    }
    let same: Vec<&&Node> = cands
        .iter()
        .filter(|n| modpath_of(&n.id) == source_mod)
        .collect();
    if same.len() == 1 {
        return vec![(same[0].id.clone(), ReferenceBasis::LocalLexical)];
    }
    cands
        .iter()
        .map(|n| (n.id.clone(), ReferenceBasis::UnresolvedAmbiguous))
        .collect()
}

fn basis_label(b: EdgeBasis) -> &'static str {
    match b {
        EdgeBasis::Resolved => "resolved",
        EdgeBasis::Lexical => "lexical",
        EdgeBasis::Unresolved => "unresolved",
    }
}

/// Resolved incoming-dependency count per symbol id — the **honest coupling
/// primitive**. Only `resolved` `references` edges count; `lexical`/`unresolved`
/// ones are excluded, so a coupling number minted from this can honestly band
/// Strong. This is what makes Nym's `incoming_deps` (captured-but-unminted at S1)
/// mintable at S4.
pub fn resolved_incoming_deps(edges: &[ProvEdge]) -> BTreeMap<String, u32> {
    let mut counts = BTreeMap::new();
    for e in edges
        .iter()
        .filter(|e| e.relation == REFERENCES && e.basis == EdgeBasis::Resolved)
    {
        *counts.entry(e.to.clone()).or_insert(0) += 1;
    }
    counts
}

#[cfg(test)]
mod s3_tests {
    use super::*;
    use std::{fs, path::PathBuf};

    fn write(root: &Path, rel: &str, src: &str) {
        let p = root.join(rel);
        fs::create_dir_all(p.parent().unwrap()).unwrap();
        fs::write(p, src).unwrap();
    }

    /// A fixture project exercising every provenance band.
    fn fixture(tag: &str) -> PathBuf {
        let root = std::env::temp_dir().join(format!("glyph-s3-{tag}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        // globally-unique callee → resolved reference.
        write(
            &root,
            "src/uniq.rs",
            "pub fn caller_u() { target_u(); }\npub fn target_u() {}\n",
        );
        // `shared` is defined in two modules → ambiguous globally.
        write(
            &root,
            "src/dup_a.rs",
            "pub fn shared() {}\npub fn caller_same() { shared(); }\n",
        );
        write(&root, "src/dup_b.rs", "pub fn shared() {}\n");
        // caller in a third module → can't disambiguate → unresolved.
        write(&root, "src/amb.rs", "pub fn caller_amb() { shared(); }\n");
        // A cross-module reference WITH a `use` import → import-backed, earned Resolved.
        write(
            &root,
            "src/importer.rs",
            "use crate::uniq::target_u;\npub fn caller_imp() { target_u(); }\n",
        );
        // local trait impl (resolved) + foreign trait impl (unresolved).
        write(
            &root,
            "src/impls.rs",
            "pub trait LocalTrait {}\npub struct MyType;\nimpl LocalTrait for MyType {}\nimpl From<u8> for MyType { fn from(_x: u8) -> Self { MyType } }\n",
        );
        root
    }

    fn refs<'a>(edges: &'a [ProvEdge], from_sub: &str, to_sub: &str) -> Vec<&'a ProvEdge> {
        edges
            .iter()
            .filter(|e| {
                e.relation == REFERENCES && e.from.contains(from_sub) && e.to.contains(to_sub)
            })
            .collect()
    }

    #[test]
    fn unique_name_reference_is_resolved() {
        let root = fixture("uniq");
        let edges = resolve_edges(&root);
        let hit = refs(&edges, "caller_u", "target_u");
        assert_eq!(hit.len(), 1, "one edge caller_u -> target_u");
        assert_eq!(
            hit[0].basis,
            EdgeBasis::Resolved,
            "globally-unique callee is resolved"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn import_backed_cross_module_reference_is_resolved() {
        // A cross-module ref WITH a `use` is import-evidenced — earned Resolved (ImportAware).
        let root = fixture("import-aware");
        let edges = resolve_edges(&root);
        let hit = refs(&edges, "caller_imp", "target_u");
        assert_eq!(
            hit.len(),
            1,
            "one import-backed edge caller_imp -> target_u"
        );
        assert_eq!(
            hit[0].basis,
            EdgeBasis::Resolved,
            "an imported cross-module reference is trusted"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn same_module_disambiguation_is_lexical() {
        let root = fixture("lex");
        let edges = resolve_edges(&root);
        // caller_same and one `shared` share a module → disambiguated, but only heuristically.
        let hit = refs(&edges, "caller_same", "shared");
        assert_eq!(hit.len(), 1, "resolves to the same-module shared only");
        assert_eq!(hit[0].basis, EdgeBasis::Lexical);
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn compiled_reference_edges_carry_core_basis() {
        let root = fixture("core-basis");
        let graph = crate::glyph::compile_code(&root);

        let basis = |from_sub: &str, to_sub: &str| {
            graph
                .edges
                .iter()
                .find(|edge| {
                    edge.relation == REFERENCES
                        && edge.from.contains(from_sub)
                        && edge.to.contains(to_sub)
                })
                .map(|edge| edge.basis)
        };

        assert_eq!(
            basis("caller_u", "target_u"),
            Some(CoreEdgeBasis::Resolved),
            "globally unique references are trusted in the core graph"
        );
        assert_eq!(
            basis("caller_same", "shared"),
            Some(CoreEdgeBasis::Lexical),
            "same-module disambiguation remains lexical in the core graph"
        );

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn ambiguous_reference_is_unresolved_to_every_candidate() {
        let root = fixture("amb");
        let edges = resolve_edges(&root);
        let hit = refs(&edges, "caller_amb", "shared");
        assert_eq!(
            hit.len(),
            2,
            "unresolved → emitted to BOTH shared candidates, not dropped"
        );
        assert!(hit.iter().all(|e| e.basis == EdgeBasis::Unresolved));
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn structural_and_impl_bands() {
        let root = fixture("struct");
        let edges = resolve_edges(&root);

        // containment is syntactically proven → resolved.
        assert!(
            edges
                .iter()
                .any(|e| e.relation == CONTAINS && e.basis == EdgeBasis::Resolved),
            "contains edges are resolved"
        );
        // local trait impl resolves to a known trait node → resolved.
        let local = edges
            .iter()
            .find(|e| e.relation == IMPLEMENTS && e.to.contains("LocalTrait"));
        assert_eq!(
            local.map(|e| e.basis),
            Some(EdgeBasis::Resolved),
            "local impl is resolved"
        );
        // foreign trait (std `From`) is outside the compile → unresolved.
        let foreign = edges
            .iter()
            .find(|e| e.relation == IMPLEMENTS && e.to.contains("From"));
        assert_eq!(
            foreign.map(|e| e.basis),
            Some(EdgeBasis::Unresolved),
            "foreign impl is unresolved"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn resolved_incoming_deps_counts_only_resolved_references() {
        let root = fixture("deps");
        let edges = resolve_edges(&root);
        let deps = resolved_incoming_deps(&edges);

        // target_u has two resolved incoming references: the same-module call (caller_u,
        // globally-unique) and the import-backed cross-module call (caller_imp, ImportAware).
        let target_u = deps.iter().find(|(k, _)| k.contains("target_u"));
        assert_eq!(target_u.map(|(_, v)| *v), Some(2));
        // `shared`'s only incoming refs are lexical + unresolved → it never counts.
        assert!(
            !deps.keys().any(|k| k.contains("shared")),
            "lexical/unresolved edges must not inflate honest coupling"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn receiver_type_disambiguates_an_ambiguous_method_name() {
        // The core of Python method-call resolution: `append` exists on two classes; a receiver type
        // of `Memory` must resolve to Memory::append (ReceiverResolved → Resolved), while WITHOUT a
        // receiver the same name is UnresolvedAmbiguous (dropped).
        let mk = |id: &str| Node {
            id: id.to_string(),
            kind: Kind::Function,
            subkind: None,
            title: "append".to_string(),
            summary: String::new(),
            aliases: vec![],
            tags: vec![],
            query_examples: vec![],
            source_files: vec![],
            partition: None,
            span: None,
        };
        let mem = mk("fn.memory::Memory::append");
        let vec = mk("fn.memory::VectorMemory::append");
        let cands = [&mem, &vec];

        let resolved = classify_reference_evidence(&cands, "agent::Agent", None, Some("Memory"));
        assert_eq!(
            resolved,
            vec![(
                "fn.memory::Memory::append".to_string(),
                ReferenceBasis::ReceiverResolved
            )],
            "receiver type Memory must pick Memory::append"
        );

        let ambiguous = classify_reference_evidence(&cands, "agent::Agent", None, None);
        assert!(
            ambiguous
                .iter()
                .all(|(_, b)| *b == ReferenceBasis::UnresolvedAmbiguous),
            "without a receiver the ambiguous name resolves to nothing trusted"
        );
    }

    #[test]
    fn enclosing_type_reads_the_member_owner() {
        assert_eq!(
            enclosing_type_of("fn.memory::Memory::append"),
            Some("Memory")
        );
        // A top-level symbol's "enclosing" is the module segment (with kind prefix) — it just never
        // equals a real receiver type, so no false receiver match.
        assert_eq!(enclosing_type_of("fn.mod::top_level"), Some("fn.mod"));
        assert_eq!(enclosing_type_of("bare"), None);
    }
}
