//! Cross-linking the docs graph (Aun) and the code graph (Glyph) into one queryable graph.
//!
//! The two compile sources stay INDEPENDENT — `eidos_aun::compile::build_graph` for docs,
//! `compile_code` for code. `link` is a JOIN: it unions their nodes + internal edges and adds
//! CROSS-LINK edges between them, so grounding a concept can surface both the doc that explains it
//! and the symbol that implements it. Node ids never collide (docs use `skill.`/`doc.` prefixes,
//! code uses `fn.`/`type.`/`trait.`/`mod.`).
//!
//! v0 lane: LEXICAL (untrusted) — a doc whose text names a distinctive code symbol gets a
//! `mentions` edge to it. It's the candidate class (not walked in expansion, exactly like an
//! intra-doc mention), so it needs no change to the frozen core read side. The TRUSTED
//! author-declared lane (doc frontmatter `documents:` / a `[[code:id]]` wikilink) requires the docs
//! compiler to capture it — a core change — and is deferred behind explicit approval.

use eidos_kernel::retrieval::{GroundIndex, ground_with};
use eidos_kernel::schema::relation::MENTIONS;
use eidos_kernel::schema::{Edge, Graph, Node};
use serde::Serialize;

/// Max cross-links emitted from one doc — bounds fan-out, mirroring the docs `mentions` cap.
const CROSS_LINK_CAP: usize = 25;
/// Minimum alphanumeric symbol length for untrusted lexical doc↔code links. Short identifiers are
/// often ordinary words, so they need authored/resolved evidence before they should connect graphs.
const MIN_DISTINCT_SYMBOL_CHARS: usize = 6;

/// Join the docs graph and the code graph into one queryable graph (deterministic: nodes + edges
/// sorted and de-duplicated). Adds lexical cross-link edges from docs to the code symbols they name.
pub fn link(docs: &Graph, code: &Graph) -> Graph {
    let mut nodes: Vec<Node> = docs
        .nodes
        .iter()
        .chain(code.nodes.iter())
        .cloned()
        .collect();
    let mut edges: Vec<Edge> = docs
        .edges
        .iter()
        .chain(code.edges.iter())
        .cloned()
        .collect();
    edges.extend(lexical_cross_links(docs, code));

    nodes.sort_by(|a, b| a.id.cmp(&b.id));
    nodes.dedup_by(|a, b| a.id == b.id);
    edges.sort_by(|a, b| (&a.from, &a.to, &a.relation).cmp(&(&b.from, &b.to, &b.relation)));
    edges.dedup_by(|a, b| a.from == b.from && a.to == b.to && a.relation == b.relation);
    Graph {
        nodes,
        edges,
        ..Default::default()
    }
}

/// Doc → code `mentions` edges: a doc whose text contains a distinctive code symbol name (as a
/// whole word) references that symbol. Untrusted/candidate, capped per doc.
fn lexical_cross_links(docs: &Graph, code: &Graph) -> Vec<Edge> {
    let symbols: Vec<(String, &str)> = code
        .nodes
        .iter()
        .filter(|n| is_distinctive(&n.title))
        .map(|n| (n.title.to_lowercase(), n.id.as_str()))
        .collect();

    let mut out = Vec::new();
    for d in &docs.nodes {
        let surface = doc_surface(d);
        let mut emitted = 0usize;
        for (name, id) in &symbols {
            if emitted >= CROSS_LINK_CAP {
                break;
            }
            if whole_word(&surface, name) {
                out.push(Edge {
                    from: d.id.clone(),
                    to: (*id).to_string(),
                    relation: MENTIONS.into(),
                    evidence: format!("cross-link: doc names `{name}`"),
                    // Lexical co-occurrence (doc text names a code symbol), NOT syntax or
                    // authoring — explicitly untrusted. The old `..Default::default()` inherited
                    // Resolved under the pre-D1 polarity, minting trust this never earned.
                    basis: eidos_kernel::schema::EdgeBasis::Lexical,
                    ..Default::default()
                });
                emitted += 1;
            }
        }
    }
    out
}

/// A code symbol name distinctive enough to mean a real cross-reference. This drops common short
/// identifiers (`new`/`get`/`from`) and short type names (`Hit`/`Node`/`Span`) whose lowercase forms
/// are ordinary English words — precision over recall, matching the docs `mentions`.
fn is_distinctive(title: &str) -> bool {
    title.chars().filter(|c| c.is_alphanumeric()).count() >= MIN_DISTINCT_SYMBOL_CHARS
        && title.chars().any(|c| c.is_ascii_alphabetic())
}

/// The lowercased text surface of a doc node a symbol name might appear in.
fn doc_surface(n: &Node) -> String {
    let mut s = format!("{}\n{}", n.title.to_lowercase(), n.summary.to_lowercase());
    for a in &n.aliases {
        s.push('\n');
        s.push_str(&a.to_lowercase());
    }
    s
}

/// Whole-word containment: `ground` matches `ground` but not inside `background` or `ground_truth`
/// (identifier characters — alphanumeric or `_` — count as word characters).
fn whole_word(hay: &str, word: &str) -> bool {
    let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
    let bytes = hay.as_bytes();
    let mut from = 0;
    while let Some(rel) = hay[from..].find(word) {
        let start = from + rel;
        let end = start + word.len();
        let before_ok = start == 0 || !is_word(bytes[start - 1]);
        let after_ok = end == bytes.len() || !is_word(bytes[end]);
        if before_ok && after_ok {
            return true;
        }
        from = start + 1;
    }
    false
}

/// One cross-domain golden case: a concept query that should surface BOTH a doc that explains it
/// and a code symbol that implements it. `expect_doc`/`expect_code` are acceptable id sets per side.
#[derive(Clone, Debug)]
pub struct CrossCase {
    pub query: String,
    pub expect_doc: Vec<String>,
    pub expect_code: Vec<String>,
}

/// Per-case cross-domain outcome over the merged graph's top-k hits.
#[derive(Serialize, Clone, Debug)]
pub struct CrossCaseResult {
    pub query: String,
    /// 1-based rank of the first expected doc id within top-k (None = not surfaced).
    pub doc_rank: Option<usize>,
    /// 1-based rank of the first expected code id within top-k (None = not surfaced).
    pub code_rank: Option<usize>,
    /// Both a doc and a code answer surfaced for this query.
    pub both: bool,
}

/// Aggregate cross-domain quality: does grounding the merged graph surface both sources?
#[derive(Serialize, Clone, Debug)]
pub struct CrossReport {
    pub cases: usize,
    /// Fraction of cases where BOTH an expected doc AND an expected code hit appear in top-k.
    pub both_sources: f64,
    /// Fraction with ≥1 expected doc in top-k.
    pub doc_recall: f64,
    /// Fraction with ≥1 expected code in top-k.
    pub code_recall: f64,
    pub per: Vec<CrossCaseResult>,
}

/// Score cross-domain retrieval: for each case, `ground` the merged graph and check whether an
/// expected doc and an expected code symbol both surface in the top-k. The measure of whether the
/// LEXICAL merge alone is enough — or whether the trusted (walked) cross-link lane is needed.
pub fn cross_domain_eval(graph: &Graph, cases: &[CrossCase], k: usize) -> CrossReport {
    let frac = |n: usize, d: usize| if d == 0 { 0.0 } else { n as f64 / d as f64 };
    let (mut both_n, mut doc_n, mut code_n) = (0usize, 0usize, 0usize);
    let mut per = Vec::with_capacity(cases.len());
    let index = GroundIndex::build(graph); // precompute once — same results, no per-case rebuild

    for c in cases {
        let hits = ground_with(graph, &index, &c.query, k);
        let rank_of = |want: &[String]| {
            hits.iter()
                .position(|h| want.iter().any(|w| w == &h.id))
                .map(|i| i + 1)
        };
        let doc_rank = rank_of(&c.expect_doc);
        let code_rank = rank_of(&c.expect_code);
        let both = doc_rank.is_some() && code_rank.is_some();
        both_n += both as usize;
        doc_n += doc_rank.is_some() as usize;
        code_n += code_rank.is_some() as usize;
        per.push(CrossCaseResult {
            query: c.query.clone(),
            doc_rank,
            code_rank,
            both,
        });
    }

    CrossReport {
        cases: cases.len(),
        both_sources: frac(both_n, cases.len()),
        doc_recall: frac(doc_n, cases.len()),
        code_recall: frac(code_n, cases.len()),
        per,
    }
}

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

    fn node(id: &str, kind: Kind, title: &str, summary: &str, file: &str) -> Node {
        Node {
            id: id.into(),
            kind,
            partition: None,
            subkind: None,
            title: title.into(),
            summary: summary.into(),
            aliases: Vec::new(),
            tags: Vec::new(),
            query_examples: Vec::new(),
            source_files: vec![file.into()],
            span: Some(Span {
                path: file.into(),
                start_line: 1,
                end_line: 1,
            }),
        }
    }
    fn doc(id: &str, summary: &str) -> Node {
        node(id, Kind::Doc, id, summary, "d.md")
    }
    fn code(id: &str) -> Node {
        node(
            id,
            Kind::Function,
            id.rsplit("::").next().unwrap_or(id),
            "",
            "c.rs",
        )
    }

    #[test]
    fn cross_link_mentions_are_lexical_never_resolved() {
        // A doc whose text names a code symbol is lexical co-occurrence, not syntax or authoring.
        // The cross-link mention MUST be Lexical — under the old Resolved-default polarity it
        // silently inherited trust (audit H2). This is the regression pin.
        let docs = Graph {
            nodes: vec![doc("doc.retry", "See reconcile_scheduler for details.")],
            ..Default::default()
        };
        let code = Graph {
            nodes: vec![code("fn.sched::reconcile_scheduler")],
            ..Default::default()
        };

        let joined = link(&docs, &code);
        let mention = joined
            .edges
            .iter()
            .find(|e| e.relation == "mentions" && e.to == "fn.sched::reconcile_scheduler")
            .expect("doc should mention the named symbol");
        assert_eq!(
            mention.basis,
            EdgeBasis::Lexical,
            "cross-link mentions must be untrusted (lexical), never inherit Resolved"
        );
    }
}
