//! `ckg orphan-calls` — find Calls edges whose `dst` doesn't resolve to
//! any Symbol. Surfaces references to deleted / renamed targets that
//! `ckg dead-code` can't catch (because the target is no longer a
//! Symbol, just a dangling text-form reference).
//!
//! Common case: you delete `def archive` from a service but leave the
//! spec calling `service.archive(...)`. RSpec catches it at runtime;
//! `orphan-calls` catches it without running tests.

use std::path::PathBuf;

use anyhow::Result;
use clap::Args as ClapArgs;
use cozo::DataValue;

#[derive(ClapArgs, Debug)]
pub struct Args {
    /// Maximum number of orphan-call rows to print.
    #[arg(long, default_value_t = 100)]
    pub limit: u32,
    /// Substring filter on the dangling `dst` text — useful when looking
    /// for references to a specific deleted symbol (e.g. `--matches
    /// UserService`).
    #[arg(long, value_name = "PATTERN")]
    pub matches: Option<String>,
    /// Show ALL orphan calls including stdlib leaves (`iter`, `len`,
    /// `Ok`, …). By default, only "interesting" orphans surface:
    /// qualified targets (`Foo::bar` / `obj.method`) whose head
    /// identifier matches a Symbol in this repo — i.e. a reference to
    /// what looks like internal code that no longer exists.
    #[arg(long)]
    pub all: bool,
    /// Include calls originating from `@external` (third-party /
    /// vendored) symbols. Off by default — your code's orphans first.
    #[arg(long)]
    pub include_external: bool,
}

pub fn run(home: Option<PathBuf>, args: Args) -> Result<()> {
    let (_base, storage) = super::repo_lookup::open_for(home, None)?;
    // Calls whose `dst` isn't a Symbol id — i.e. the cross-file
    // resolver couldn't link the target. Confidence < 1.0 cuts out
    // file-local exact-match edges (their dst is always a real id).
    // Add caller `doc` to support the @external filter without a
    // second pass. The conf < 1.0 cut keeps file-local exact matches
    // out of the result set.
    let script = "
        ?[caller, file, line, dst, conf, src_doc] :=
          *Calls{src, dst, confidence: conf},
          conf < 1.0,
          not *Symbol{id: dst},
          *Symbol{id: src, qname: caller, file, line, doc: src_doc}
        :limit $limit
    ";
    // CR3 fix: oversample 10× because the post-filter (interesting +
    // --matches) can prune most rows. Truncating at the user's
    // `--limit` BEFORE the filter produced silent under-reporting.
    let mut params = std::collections::BTreeMap::new();
    params.insert(
        "limit".into(),
        DataValue::from((args.limit.saturating_mul(10).max(1)) as i64),
    );
    let rows = storage.run_with_immutable(script, params)?;

    // Build the set of "internal" head identifiers — Symbol names that
    // exist in this repo. An orphan call whose head matches one of
    // these is interesting (it references something that LOOKS like
    // internal code but doesn't resolve); orphans rooted at unknown
    // heads are typically stdlib / external crates.
    let mut head_set: std::collections::HashSet<String> = std::collections::HashSet::new();
    if !args.all {
        let names = storage.run_immutable("?[name] := *Symbol{name}")?;
        for r in names.rows {
            if let Some(DataValue::Str(s)) = r.into_iter().next() {
                head_set.insert(s.to_string());
            }
        }
    }

    let needle = args.matches.as_deref();
    let s = |v: &DataValue| match v {
        DataValue::Str(s) => s.to_string(),
        DataValue::Num(n) => format!("{n:?}"),
        v => format!("{v:?}"),
    };
    let interesting = |dst: &str| -> bool {
        if args.all {
            return true;
        }
        // Must be qualified (`::` or `.`).
        let head = match dst.find("::").or_else(|| dst.find('.')) {
            Some(i) => &dst[..i],
            None => return false,
        };
        // Head must match a known Symbol name.
        head_set.contains(head)
    };
    let include_external = args.include_external;
    let filtered: Vec<_> = rows
        .rows
        .into_iter()
        .filter(|r| {
            // @external caller filter (default off).
            if !include_external {
                if let Some(DataValue::Str(d)) = r.get(5) {
                    if d.contains("@external") {
                        return false;
                    }
                }
            }
            match r.get(3) {
                Some(DataValue::Str(d)) => {
                    let d = d.as_str();
                    if !interesting(d) {
                        return false;
                    }
                    match needle {
                        Some(p) => d.contains(p),
                        None => true,
                    }
                }
                _ => false,
            }
        })
        .take(args.limit.max(1) as usize) // IM6: --limit 0 → at least 1
        .collect();

    if filtered.is_empty() {
        println!("(no orphan calls)");
        return Ok(());
    }
    println!("orphan calls: {}", filtered.len());
    println!("{:<60} {:<40} FILE:LINE", "CALLER", "MISSING TARGET");
    for row in filtered.iter() {
        let caller = s(&row[0]);
        let file = s(&row[1]);
        let line = s(&row[2]);
        let dst = s(&row[3]);
        println!("{caller:<60} {dst:<40} {file}:{line}");
    }
    Ok(())
}
