//! `ckg symbol <name>` — locate a symbol and show callers/callees/members.
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 {
    pub name: String,
    #[arg(long)]
    pub callers: bool,
    #[arg(long)]
    pub callees: bool,
    #[arg(long)]
    pub members: bool,
    /// Include `@external` symbols (third-party / vendored code) in
    /// results. Off by default — your code 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)?;
    let mut params = BTreeMap::new();
    params.insert("q".into(), DataValue::from(args.name.as_str()));
    let rows = storage.run_with_immutable(
        "?[id, qname, kind, file, line] := *Symbol{id, qname, kind, file, line}, \
         is_in(qname, $q)",
        params,
    );
    // Cozo `is_in` may not exist; fall back to a naive contains via Datalog string ops.
    let mut rows = match rows {
        Ok(r) => r,
        Err(_) => {
            let mut params = BTreeMap::new();
            params.insert("q".into(), DataValue::from(args.name.as_str()));
            storage.run_with_immutable(
                "?[id, qname, kind, file, line] := *Symbol{id, qname, kind, file, line}, \
                 str_includes(qname, $q)",
                params,
            )?
        }
    };
    // Lifetime / generic fallback: if the user typed `Ctx::push_symbol`
    // but the qname is `Ctx<'_>::push_symbol`, str_includes misses.
    // Fetch all Symbols, strip generics from each qname in Rust, and
    // check substring match.
    if rows.rows.is_empty() {
        // L8: cap fallback full-table scan at MAX_SCAN_ROWS so a large
        // monorepo doesn't OOM the CLI. Warn when the cap fires.
        const MAX_SCAN_ROWS: usize = 200_000;
        let all = storage.run_immutable(&format!(
            "?[id, qname, kind, file, line] := *Symbol{{id, qname, kind, file, line}} \
             :limit {MAX_SCAN_ROWS}"
        ))?;
        if all.rows.len() >= MAX_SCAN_ROWS {
            eprintln!(
                "(warning: symbol fallback scan capped at {MAX_SCAN_ROWS}; \
                 generic-stripped match may be incomplete)"
            );
        }
        let needle = args.name.as_str();
        let filtered: Vec<_> = all
            .rows
            .into_iter()
            .filter(|r| match r.get(1) {
                Some(DataValue::Str(q)) => strip_generics(q.as_str()).contains(needle),
                _ => false,
            })
            .collect();
        if !filtered.is_empty() {
            rows.rows = filtered;
        }
    }
    // @external filter (default behavior — pre-fetch each row's doc).
    // Done in Rust to avoid duplicating the lookup-fallback chain above.
    if !args.include_external && !rows.rows.is_empty() {
        let ids: Vec<DataValue> = rows
            .rows
            .iter()
            .filter_map(|r| match r.first() {
                Some(DataValue::Str(s)) => Some(DataValue::from(s.as_str())),
                _ => None,
            })
            .collect();
        let mut p = BTreeMap::new();
        p.insert("ids".into(), DataValue::List(ids));
        if let Ok(docs) = storage.run_with_immutable(
            "?[id, doc] := *Symbol{id, doc}, is_in(id, $ids)",
            p,
        ) {
            let external: std::collections::HashSet<String> = docs
                .rows
                .into_iter()
                .filter_map(|r| match (r.first(), r.get(1)) {
                    (Some(DataValue::Str(id)), Some(DataValue::Str(d)))
                        if d.contains("@external") =>
                    {
                        Some(id.to_string())
                    }
                    _ => None,
                })
                .collect();
            rows.rows.retain(|r| match r.first() {
                Some(DataValue::Str(id)) => !external.contains(id.as_str()),
                _ => true,
            });
        }
    }
    if rows.rows.is_empty() {
        println!("(no matches for {:?})", args.name);
        return Ok(());
    }
    println!("{:<8} {:<60} {:<10} FILE", "KIND", "QNAME", "LINE");
    for row in rows.rows.iter().take(50) {
        let s = |i: usize| match &row[i] {
            DataValue::Str(s) => s.to_string(),
            DataValue::Num(n) => format!("{n:?}"),
            v => format!("{v:?}"),
        };
        println!("{:<8} {:<60} {:<10} {}", s(2), s(1), s(4), s(3));
    }
    if !args.callers && !args.callees && !args.members {
        return Ok(());
    }
    // For first hit, show neighbors.
    let id = match &rows.rows[0][0] {
        DataValue::Str(s) => s.to_string(),
        _ => return Ok(()),
    };
    if args.callers {
        let mut p = BTreeMap::new();
        p.insert("id".into(), DataValue::from(id.as_str()));
        let r = storage.run_with_immutable("?[src, conf] := *Calls{src, dst: $id, confidence: conf}", p)?;
        println!("\nCALLERS ({}):", r.rows.len());
        for row in r.rows.iter().take(30) {
            println!("  {:?}", row[0]);
        }
    }
    if args.callees {
        let mut p = BTreeMap::new();
        p.insert("id".into(), DataValue::from(id.as_str()));
        let r = storage.run_with_immutable("?[dst, conf] := *Calls{src: $id, dst, confidence: conf}", p)?;
        println!("\nCALLEES ({}):", r.rows.len());
        for row in r.rows.iter().take(30) {
            println!("  {:?}", row[0]);
        }
    }
    Ok(())
}

/// Bracket-balanced strip of `<...>` generic / lifetime annotations.
/// `Ctx<'_>::push_symbol` → `Ctx::push_symbol`. Mirrors the helper in
/// `ckg-storage::store::strip_generics`.
fn strip_generics(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut depth: usize = 0;
    for c in s.chars() {
        match c {
            '<' => depth += 1,
            '>' if depth > 0 => depth -= 1,
            _ if depth == 0 => out.push(c),
            _ => {}
        }
    }
    out
}
