//! `ckg vendor-uses <name>` — find first-party callers of a vendored /
//! third-party symbol.
//!
//! Inverse of `--include-external` on the other read commands: instead
//! of hiding `@external` rows we focus exclusively on them, and surface
//! the YOUR-code callers. Useful for:
//!   * "what touches `aws_sdk_s3::Client::put_object`?"
//!   * "what calls anything in `serde_json`?"
//!
//! `--deprecated` further narrows callees to symbols whose doc carries
//! `@deprecated` — the typical migration question.

use std::collections::BTreeMap;
use std::path::PathBuf;

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

#[derive(ClapArgs, Debug)]
pub struct Args {
    /// Substring matched against the @external symbol name. Use `--all`
    /// to skip the filter entirely (with `--deprecated` this becomes
    /// "every deprecated vendored symbol called from my code").
    #[arg(default_value = "")]
    pub name: String,
    /// Skip the name filter — match every @external symbol.
    #[arg(long)]
    pub all: bool,
    /// Narrow to callees whose doc also carries `@deprecated`.
    #[arg(long)]
    pub deprecated: bool,
    /// Maximum rows to print.
    #[arg(long, default_value_t = 200)]
    pub limit: u32,
}

pub fn run(home: Option<PathBuf>, args: Args) -> Result<()> {
    let (_base, storage) = super::repo_lookup::open_for(home, None)?;
    if args.name.trim().is_empty() && !args.all {
        anyhow::bail!(
            "vendor-uses needs a name substring (e.g. `ckg vendor-uses Client`) or `--all`"
        );
    }
    let rows = query_vendor_uses(
        &storage,
        if args.all { None } else { Some(args.name.as_str()) },
        args.deprecated,
        args.limit,
    )?;
    if rows.rows.is_empty() {
        if args.all {
            println!("(no first-party callers of any @external symbol)");
        } else {
            println!("(no first-party callers of @external symbols matching {:?})", args.name);
        }
        return Ok(());
    }
    println!(
        "vendor-uses{}: {} call sites",
        if args.deprecated { " (deprecated only)" } else { "" },
        rows.rows.len()
    );
    println!("{:<60} {:<60} FILE:LINE", "CALLER", "CALLEE");
    let s = |v: &DataValue| match v {
        DataValue::Str(s) => s.to_string(),
        DataValue::Num(n) => format!("{n:?}"),
        v => format!("{v:?}"),
    };
    for row in rows.rows.iter().take(args.limit as usize) {
        let caller = s(&row[0]);
        let file = s(&row[1]);
        let line = s(&row[2]);
        let callee = s(&row[3]);
        println!("{caller:<60} {callee:<60} {file}:{line}");
    }
    Ok(())
}

/// Thin wrapper: `ckg deprecated-deps` == `ckg vendor-uses --all --deprecated`.
/// Reuses the vendor-uses pipeline so future fixes don't have to be applied
/// twice.
#[derive(ClapArgs, Debug)]
pub struct DeprecatedDepsArgs {
    /// Maximum rows to print.
    #[arg(long, default_value_t = 200)]
    pub limit: u32,
}

pub fn run_deprecated_deps(home: Option<PathBuf>, args: DeprecatedDepsArgs) -> Result<()> {
    run(
        home,
        Args {
            name: String::new(),
            all: true,
            deprecated: true,
            limit: args.limit,
        },
    )
}

/// Core vendor-uses query — exposed as a library function so integration
/// tests can exercise the Datalog shape against a synthetic Storage
/// without going through the CLI / `home` resolution.
///
/// `needle`: `None` matches every `@external` callee (the `--all` mode);
/// `Some(s)` requires the callee's `name` to contain `s` as substring.
/// `deprecated_only`: when true, the callee's `doc` must also carry
/// `@deprecated`.
///
/// Returns rows in the shape `[caller_qname, caller_file, caller_line,
/// callee_qname]`. Caller's doc is implicitly NOT `@external` (vendored
/// → vendored chains are excluded — uninteresting for "what does MY
/// code touch?" queries).
pub fn query_vendor_uses(
    storage: &ckg_storage::Storage,
    needle: Option<&str>,
    deprecated_only: bool,
    limit: u32,
) -> Result<cozo::NamedRows> {
    let deprecated_clause = if deprecated_only {
        "str_includes(callee_doc, \"@deprecated\"),"
    } else {
        ""
    };
    let name_clause = if needle.is_some() {
        "str_includes(name, $needle),"
    } else {
        ""
    };
    // Datalog: callee is @external (post-SCIP-persist tag), caller is
    // a Symbol whose doc is NOT @external. Joins through the Calls edge.
    // Name filter runs against the callee's `name` rather than `qname`
    // so users type the bare identifier they remember (`Client`,
    // `put_object`) instead of the full SCIP path.
    let script = format!(
        "?[caller_qname, caller_file, caller_line, callee_qname] := \
             *Symbol{{id: callee_id, qname: callee_qname, doc: callee_doc, name}}, \
             str_includes(callee_doc, \"@external\"), \
             {name_clause} \
             {deprecated_clause} \
             *Calls{{src: caller_id, dst: callee_id}}, \
             *Symbol{{id: caller_id, qname: caller_qname, file: caller_file, line: caller_line, doc: caller_doc}}, \
             not str_includes(caller_doc, \"@external\") \
             :limit $limit"
    );
    let mut params = BTreeMap::new();
    params.insert(
        "needle".into(),
        DataValue::from(needle.unwrap_or("")),
    );
    params.insert("limit".into(), DataValue::from(limit as i64));
    storage
        .run_with_immutable(&script, params)
        .map_err(anyhow::Error::from)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ckg_core::{Edge, EdgeKind, Kind, RepoId, Symbol};
    use cozo::DataValue;

    /// Open a fresh Storage in a tempdir + seed it with:
    ///   - 1 first-party caller, 1 first-party helper (no Calls)
    ///   - 1 vendored callee tagged `@external`
    ///   - 1 vendored callee tagged `@external @deprecated`
    ///   - 1 vendored caller calling a vendored callee (vendored → vendored)
    ///   - Calls edges wiring first-party caller → both vendored callees,
    ///     and vendored → vendored.
    fn seeded_storage() -> (tempfile::TempDir, ckg_storage::Storage) {
        let tmp = tempfile::tempdir().unwrap();
        let repo_id = RepoId::try_new("a".repeat(24)).unwrap();
        let storage = ckg_storage::Storage::open_unverified(repo_id, tmp.path()).unwrap();

        let mk = |id: &str, qname: &str, name: &str, file: &str, doc: &str| Symbol {
            id: id.into(),
            qname: qname.into(),
            name: name.into(),
            kind: Kind::Method,
            file: file.into(),
            line: 1,
            col: 0,
            is_public: true,
            doc: doc.into(),
            hash: String::new(),
        };

        let symbols = vec![
            // First-party caller — touches both vendored symbols.
            mk("first_caller", "MyApp.Handler.send", "send", "app/handler.rb", ""),
            // First-party helper — no Calls anywhere; should never appear.
            mk("first_helper", "MyApp.Util.calc", "calc", "app/util.rb", ""),
            // Vendored callee 1 — plain @external.
            mk(
                "ext_client",
                "aws_sdk_s3::Client::put_object",
                "put_object",
                ".cargo/registry/aws.rs",
                "@external",
            ),
            // Vendored callee 2 — @external + @deprecated.
            mk(
                "ext_deprecated",
                "old_lib::deprecated_thing",
                "deprecated_thing",
                ".cargo/registry/old.rs",
                "@external @deprecated",
            ),
            // Vendored caller — chains into vendored callee (vendor → vendor).
            // Must be FILTERED OUT by vendor-uses ("show MY code's callers").
            mk(
                "ext_caller",
                "other_crate::Wrapper::go",
                "go",
                ".cargo/registry/other.rs",
                "@external",
            ),
        ];
        storage.put_symbols(&symbols).unwrap();

        let edges = vec![
            // First-party → vendored (both targets).
            Edge {
                kind: EdgeKind::Calls,
                src: "first_caller".into(),
                dst: "ext_client".into(),
                confidence: 1.0,
            },
            Edge {
                kind: EdgeKind::Calls,
                src: "first_caller".into(),
                dst: "ext_deprecated".into(),
                confidence: 1.0,
            },
            // Vendored → vendored — should NOT surface.
            Edge {
                kind: EdgeKind::Calls,
                src: "ext_caller".into(),
                dst: "ext_client".into(),
                confidence: 1.0,
            },
        ];
        storage.put_edges(&edges).unwrap();

        (tmp, storage)
    }

    /// Pull caller_qname column out of NamedRows for easy assertions.
    fn caller_qnames(rows: &cozo::NamedRows) -> Vec<String> {
        rows.rows
            .iter()
            .filter_map(|r| match r.first() {
                Some(DataValue::Str(s)) => Some(s.to_string()),
                _ => None,
            })
            .collect()
    }

    fn callee_qnames(rows: &cozo::NamedRows) -> Vec<String> {
        rows.rows
            .iter()
            .filter_map(|r| match r.get(3) {
                Some(DataValue::Str(s)) => Some(s.to_string()),
                _ => None,
            })
            .collect()
    }

    #[test]
    fn all_mode_returns_first_party_callers_of_every_external() {
        let (_t, st) = seeded_storage();
        let rows = query_vendor_uses(&st, None, false, 100).unwrap();
        let callers = caller_qnames(&rows);
        // first_caller hits both vendored callees → 2 rows. No
        // appearance of ext_caller (vendor → vendor filtered).
        assert_eq!(rows.rows.len(), 2, "expected 2 call sites, got {rows:?}");
        assert!(callers.iter().all(|c| c == "MyApp.Handler.send"));
        let callees = callee_qnames(&rows);
        assert!(callees.contains(&"aws_sdk_s3::Client::put_object".into()));
        assert!(callees.contains(&"old_lib::deprecated_thing".into()));
    }

    #[test]
    fn name_filter_narrows_to_matching_callees() {
        let (_t, st) = seeded_storage();
        let rows = query_vendor_uses(&st, Some("put_object"), false, 100).unwrap();
        assert_eq!(rows.rows.len(), 1);
        let callees = callee_qnames(&rows);
        assert_eq!(callees, vec!["aws_sdk_s3::Client::put_object"]);
    }

    #[test]
    fn deprecated_filter_narrows_to_deprecated_callees() {
        let (_t, st) = seeded_storage();
        let rows = query_vendor_uses(&st, None, true, 100).unwrap();
        assert_eq!(rows.rows.len(), 1);
        let callees = callee_qnames(&rows);
        assert_eq!(callees, vec!["old_lib::deprecated_thing"]);
    }

    #[test]
    fn vendored_to_vendored_calls_are_excluded() {
        let (_t, st) = seeded_storage();
        let rows = query_vendor_uses(&st, None, false, 100).unwrap();
        let callers = caller_qnames(&rows);
        assert!(
            !callers.iter().any(|c| c == "other_crate::Wrapper::go"),
            "vendored callers must be filtered out, got {callers:?}"
        );
    }

    #[test]
    fn empty_repo_returns_zero_rows() {
        let tmp = tempfile::tempdir().unwrap();
        let repo_id = RepoId::try_new("b".repeat(24)).unwrap();
        let storage = ckg_storage::Storage::open_unverified(repo_id, tmp.path()).unwrap();
        let rows = query_vendor_uses(&storage, None, false, 100).unwrap();
        assert_eq!(rows.rows.len(), 0);
    }

    #[test]
    fn limit_caps_returned_rows() {
        let (_t, st) = seeded_storage();
        let rows = query_vendor_uses(&st, None, false, 1).unwrap();
        assert_eq!(rows.rows.len(), 1);
    }
}
