//! `ckg dead-code` — symbols (functions/methods) with no inbound Calls.
use std::path::PathBuf;

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

/// True if the error looks like "Cozo doesn't know this builtin / op",
/// rather than a real I/O / disk / parse failure. We only fall back on
/// these — silently swallowing the rest masks real bugs. The substrings
/// here are narrow on purpose: `"expected"` was tried earlier but masked
/// legitimate parse errors, so dropped.
fn is_unsupported_builtin(err: &ckg_core::Error) -> bool {
    let msg = err.to_string();
    msg.contains("not found")
        || msg.contains("unknown function")
        || msg.contains("Unknown function")
        || msg.contains("CannotFindOp")
        || msg.contains("undefined op")
        || msg.contains("undefined function")
}

#[derive(ClapArgs, Debug)]
pub struct Args {
    #[arg(long, default_value_t = 200)]
    pub limit: u32,
    /// Include framework entry points (Rails controllers/jobs/workers/
    /// consumers/mailers, Sidekiq workers, Karafka consumers, common
    /// callbacks like `before_*`/`after_*`, Ruby/Python dunders, etc.)
    /// in the candidate list. By default these are filtered out because
    /// they're invoked via metaprogramming / routing / reflection and
    /// would otherwise dominate the output with false positives.
    #[arg(long)]
    pub include_entry_points: bool,
    /// Include `@external` symbols (third-party / vendored). Off by
    /// default — vendored code typically has no local callers and
    /// would dominate the dead-code list otherwise.
    #[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)?;
    // Detect active language + framework adapters from the current
    // directory (which is where `open_for` looked for the indexed repo).
    // The registry replaces the hard-coded `is_likely_framework_entry`
    // post-filter — adapters own their entry-point heuristics.
    let registry = ckg_lang::AdapterRegistry::detect(
        &std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
    );
    // Collect symbol ids that have cross-repo callers — those are NOT
    // dead even though the per-repo Calls table can't see them. The
    // registry's CROSS_CALLS relation holds resolver-emitted edges from
    // other repos in the manifest into this one.
    let cross_called_ids: std::collections::HashSet<String> =
        match ckg_storage::RegistryStorage::open(&base) {
            Ok(reg) => reg
                .db()
                .run_script(
                    "?[dst] := *CROSS_CALLS{dst}",
                    std::collections::BTreeMap::new(),
                    cozo::ScriptMutability::Immutable,
                )
                .ok()
                .map(|rows| {
                    rows.rows
                        .into_iter()
                        .filter_map(|r| match r.into_iter().next() {
                            Some(DataValue::Str(s)) => Some(s.to_string()),
                            _ => None,
                        })
                        .collect()
                })
                .unwrap_or_default(),
            Err(e) => {
                // M6: surface registry-open failure so operators know
                // cross-repo data is unavailable.
                tracing::warn!("dead-code: registry open failed ({e}), cross-repo callers not excluded");
                std::collections::HashSet::new()
            }
        };
    // Datalog exclusions reflect static-analysis limits, not user policy:
    //   `@test` / `@api` doc tags — explicit opt-outs from extractors.
    //   `name = "main"` — OS-invoked entry point.
    //   `name = "default"` — Default::default impls; trait dispatch is
    //                        invisible to a tree-sitter walk.
    //   `@external` — third-party / vendored (filterable via `--include-external`).
    //
    // Phase-2 refactor: per-language test-file detection moved out of
    // Datalog into `AdapterRegistry::any_test_file` post-filter below.
    // Adding a new language now only needs an `is_test_file` impl on
    // its adapter — no `dead_code.rs` query edit. Same applies to test
    // method-name conventions (`test_*` pytest/minitest etc.) which
    // are covered by the adapter `is_entry_point` post-filter.
    let script = format!(
        "?[id, qname, kind, file, line] := \
         *Symbol{{id, qname, name, kind, file, line, doc}}, \
         is_in(kind, [\"function\", \"method\", \"constructor\"]), \
         name != \"main\", name != \"default\", \
         not str_includes(doc, \"@test\"), \
         not str_includes(doc, \"@api\"), \
         {external_clause} \
         not *Calls{{dst: id}} \
         :limit {}",
        // CR3 fix: oversample 10× because the post-filter
        // (cross-repo callers exclusion + adapter any_test_file +
        // adapter any_entry_point below) can prune most rows.
        // Truncating at the user's `--limit` BEFORE the filter
        // produced silent under-reporting.
        args.limit.saturating_mul(10).max(1),
        external_clause = if args.include_external {
            ""
        } else {
            "not str_includes(doc, \"@external\"),"
        }
    );
    let rows = match storage.run_immutable(&script) {
        Ok(r) => r,
        Err(e) if is_unsupported_builtin(&e) => {
            // First fallback: no `is_in` (older Cozo) but keep `str_includes`.
            let alt_external_clause = if args.include_external {
                ""
            } else {
                "not str_includes(doc, \"@external\"),"
            };
            let alt1 = format!(
                "?[id, qname, kind, file, line] := \
                 *Symbol{{id, qname, name, kind, file, line, doc}}, \
                 (kind = \"function\" or kind = \"method\" or kind = \"constructor\"), \
                 name != \"main\", name != \"default\", \
                 not str_includes(doc, \"@test\"), \
                 not str_includes(doc, \"@api\"), \
                 {alt_external_clause} \
                 not *Calls{{dst: id}} \
                 :limit {}",
                args.limit.saturating_mul(10).max(1)
            );
            match storage.run_immutable(&alt1) {
                Ok(r) => r,
                Err(e) if is_unsupported_builtin(&e) => {
                    // Final fallback: drop the `@test` filter entirely
                    // (older Cozo lacks `str_includes`). Tests will
                    // appear in the candidate list — same behavior as
                    // before the test-tag was introduced.
                    let alt2 = format!(
                        "?[id, qname, kind, file, line] := \
                         *Symbol{{id, qname, kind, file, line}}, \
                         (kind = \"function\" or kind = \"method\" or kind = \"constructor\"), \
                         not *Calls{{dst: id}} \
                         :limit {}",
                        args.limit.saturating_mul(10).max(1)
                    );
                    storage.run_immutable(&alt2)?
                }
                Err(e) => return Err(e.into()),
            }
        }
        Err(e) => return Err(e.into()),
    };
    // Drop candidates that have inbound cross-repo calls, then drop
    // adapter-classified test files, then (unless the user opted in)
    // drop framework entry-point heuristics. Order matters: test-file
    // filter is cheap (path-only); entry-point is more expensive
    // (path + qname + name dispatch).
    let filtered: Vec<_> = rows
        .rows
        .iter()
        .filter(|row| match &row[0] {
            DataValue::Str(id) => !cross_called_ids.contains(id.as_str()),
            _ => true,
        })
        .filter(|row| {
            // Phase-2 refactor: harness-invoked test files are filtered
            // via adapter `is_test_file` instead of hardcoded `/spec/`,
            // `_test.rb`, etc. Datalog clauses. Adding a new language
            // requires only `is_test_file` on its adapter.
            let file = match &row[3] {
                DataValue::Str(s) => s.as_str(),
                _ => return true,
            };
            !registry.any_test_file(file)
        })
        .filter(|row| {
            if args.include_entry_points {
                return true;
            }
            let qname = match &row[1] {
                DataValue::Str(s) => s.as_str(),
                _ => return true,
            };
            let file = match &row[3] {
                DataValue::Str(s) => s.as_str(),
                _ => return true,
            };
            // The qname's last segment is the bare method name —
            // strip everything before the final `.` or `::` separator.
            let name = qname
                .rsplit_once('.')
                .map(|(_, n)| n)
                .or_else(|| qname.rsplit_once("::").map(|(_, n)| n))
                .unwrap_or(qname);
            !registry.any_entry_point(file, qname, name)
        })
        .take(args.limit.max(1) as usize) // IM6: --limit 0 → at least 1
        .collect();
    // Item #20: warn the user if the candidate pool was big enough to
    // exhaust the 10× oversample window — they may be missing entries.
    if rows.rows.len() >= args.limit.saturating_mul(10).max(1) as usize
        && filtered.len() == args.limit.max(1) as usize
    {
        eprintln!(
            "(warning: 10× oversample exhausted; raise --limit if you suspect missing dead-code candidates)"
        );
    }
    println!("dead-code candidates: {}", filtered.len());
    for row in filtered.iter() {
        let q = match &row[1] {
            DataValue::Str(s) => s.to_string(),
            v => format!("{v:?}"),
        };
        let f = match &row[3] {
            DataValue::Str(s) => s.to_string(),
            v => format!("{v:?}"),
        };
        let l = match &row[4] {
            DataValue::Num(n) => format!("{n:?}"),
            v => format!("{v:?}"),
        };
        println!("  {q}  ({f}:{l})");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::is_unsupported_builtin;
    use ckg_core::Error;

    fn s(msg: &str) -> Error {
        Error::Storage(msg.into())
    }

    #[test]
    fn matches_unsupported_builtin_messages() {
        for m in [
            "operator str_includes not found",
            "Unknown function: str_includes",
            "CannotFindOp(\"is_in\")",
            "undefined op: foo",
            "undefined function bar",
        ] {
            assert!(is_unsupported_builtin(&s(m)), "should match: {m}");
        }
    }

    // Framework-entry detection now lives in per-adapter tests:
    //   crates/ckg-lang/src/lang/ruby/mod.rs       (RubyAdapter)
    //   crates/ckg-lang/src/framework/rails.rs     (RailsAdapter)
    //   crates/ckg-lang/src/framework/karafka.rs   (KarafkaAdapter)
    //   crates/ckg-lang/src/framework/sidekiq.rs   (SidekiqAdapter)
    // See plans/260512-1424-adapter-refactor/phase-01-foundation.md.

    #[test]
    fn does_not_match_real_errors() {
        for m in [
            "I/O error: disk full",
            "permission denied",
            "expected ; near line 5", // parse error — must propagate
            "value of type bool expected, got string",
            "RocksDB lock contention",
        ] {
            assert!(
                !is_unsupported_builtin(&s(m)),
                "should NOT match real error: {m}"
            );
        }
    }
}
