//! `ckg changed-symbols --pr <N>` — PR-diff specialist.
//!
//! Pulls the unified diff from GitHub via `gh pr diff <N>`, maps each hunk
//! to the enclosing function/method/constructor in the indexed graph, then
//! enriches each changed symbol with:
//!   * callers (upward, depth=1) when `--callers`
//!   * routes that name it as `handler=<qname>` (always)
//!   * direct tests via the `Tests` relation (always)
//!
//! Differentiator vs ast-graph `changed-symbols`: pulls from the GitHub PR
//! API (no checkout / branch fetch needed) and stacks route + test impact
//! on top of plain caller analysis.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process::Command;

use anyhow::{Context, Result, anyhow, bail};
use clap::Args as ClapArgs;
use cozo::DataValue;
use serde::Serialize;

#[derive(ClapArgs, Debug)]
pub struct Args {
    /// GitHub PR number (uses `gh pr view` + `gh pr diff`).
    #[arg(long)]
    pub pr: u32,
    /// Target `owner/repo` for GitHub (passes `gh -R owner/repo`).
    /// Defaults to the repo `gh` would pick up from the current
    /// directory.
    #[arg(long)]
    pub repo: Option<String>,
    /// Path to the indexed local checkout. Defaults to current
    /// directory. Use this when running from outside the project tree
    /// (combine with `--repo owner/repo` so `gh` finds the PR too).
    #[arg(long)]
    pub path: Option<PathBuf>,
    /// List upward callers (depth=1) for each changed symbol.
    #[arg(long)]
    pub callers: bool,
    /// Emit JSON instead of the human table.
    #[arg(long)]
    pub json: bool,
}

#[derive(Serialize)]
struct Report {
    pr: u32,
    title: String,
    url: String,
    base: String,
    head: String,
    files_changed: usize,
    symbols: Vec<ChangedSymbol>,
}

#[derive(Serialize)]
struct ChangedSymbol {
    id: String,
    qname: String,
    kind: String,
    file: String,
    line: i64,
    callers: Vec<String>,
    routes: Vec<String>,
    tests: Vec<String>,
}

pub fn run(home: Option<PathBuf>, args: Args) -> Result<()> {
    let (_base, storage) = super::repo_lookup::open_for(home, args.path.as_deref())?;

    let meta = gh_pr_meta(args.pr, args.repo.as_deref())?;
    let diff = gh_pr_diff(args.pr, args.repo.as_deref())?;
    let hunks = parse_diff(&diff);
    if hunks.is_empty() {
        println!("(PR #{} has no hunks in the diff)", args.pr);
        return Ok(());
    }

    // file -> sorted symbols (enclosing-eligible only)
    let mut by_file: BTreeMap<String, Vec<(i64, String, String, String)>> = BTreeMap::new();
    let touched_files: BTreeSet<&str> = hunks.iter().map(|h| h.file.as_str()).collect();
    for f in &touched_files {
        by_file.insert((*f).to_string(), file_symbols(&storage, f)?);
    }

    let mut seen: BTreeSet<String> = BTreeSet::new();
    let mut symbols: Vec<ChangedSymbol> = Vec::new();
    for h in &hunks {
        let Some(syms) = by_file.get(&h.file) else {
            continue;
        };
        let lines: Vec<i64> = if h.new_lines == 0 {
            vec![h.new_start.max(1)]
        } else {
            (h.new_start..h.new_start + h.new_lines).collect()
        };
        for ln in lines {
            let Some((id, qname, kind, sym_line)) = enclosing(syms, ln) else {
                continue;
            };
            if !seen.insert(id.clone()) {
                continue;
            }
            symbols.push(ChangedSymbol {
                id: id.clone(),
                qname: qname.clone(),
                kind: kind.clone(),
                file: h.file.clone(),
                line: sym_line,
                callers: if args.callers { callers_of(&storage, &id)? } else { vec![] },
                routes: routes_for(&storage, &qname)?,
                tests: tests_for(&storage, &id)?,
            });
        }
    }

    if args.json {
        let report = Report {
            pr: args.pr,
            title: meta.title,
            url: meta.url,
            base: meta.base_ref_name,
            head: meta.head_ref_name,
            files_changed: touched_files.len(),
            symbols,
        };
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        print_human(args.pr, &meta, touched_files.len(), &symbols, args.callers);
    }
    Ok(())
}

#[derive(Debug)]
struct Hunk {
    file: String,
    new_start: i64,
    new_lines: i64,
}

fn parse_diff(diff: &str) -> Vec<Hunk> {
    let mut out = Vec::new();
    let mut cur_file: Option<String> = None;
    for line in diff.lines() {
        if let Some(path) = line.strip_prefix("+++ b/") {
            cur_file = Some(path.to_string());
        } else if line.starts_with("+++ /dev/null") {
            cur_file = None; // file deleted — nothing to enclose
        } else if let Some(rest) = line.strip_prefix("@@ ")
            && let Some(file) = &cur_file
        {
            // @@ -old_start,old_count +new_start,new_count @@
            // counts default to 1 when omitted.
            let plus = rest.split_whitespace().find(|t| t.starts_with('+'));
            if let Some(p) = plus {
                let body = &p[1..];
                let (start, count) = match body.split_once(',') {
                    Some((s, c)) => (s.parse().unwrap_or(0), c.parse().unwrap_or(1)),
                    None => (body.parse().unwrap_or(0), 1),
                };
                if start > 0 {
                    out.push(Hunk { file: file.clone(), new_start: start, new_lines: count });
                }
            }
        }
    }
    out
}

fn file_symbols(
    storage: &ckg_storage::Storage,
    file: &str,
) -> Result<Vec<(i64, String, String, String)>> {
    let mut p = BTreeMap::new();
    p.insert("f".into(), DataValue::from(file));
    let r = storage.run_with_immutable(
        "?[line, id, qname, kind] := *Symbol{id, qname, kind, file: $f, line}, \
         (kind = \"function\" or kind = \"method\" or kind = \"constructor\")",
        p,
    )?;
    let mut out: Vec<(i64, String, String, String)> = r
        .rows
        .into_iter()
        .filter_map(|row| {
            let line = match row.get(0)? {
                DataValue::Num(cozo::Num::Int(i)) => *i,
                _ => return None,
            };
            Some((line, ds(row.get(1)?)?, ds(row.get(2)?)?, ds(row.get(3)?)?))
        })
        .collect();
    out.sort_by_key(|t| t.0);
    Ok(out)
}

fn enclosing(
    syms: &[(i64, String, String, String)],
    line: i64,
) -> Option<(String, String, String, i64)> {
    // Largest start_line <= line.
    let idx = syms.partition_point(|t| t.0 <= line);
    if idx == 0 {
        return None;
    }
    let (l, id, qname, kind) = &syms[idx - 1];
    Some((id.clone(), qname.clone(), kind.clone(), *l))
}

fn callers_of(storage: &ckg_storage::Storage, id: &str) -> Result<Vec<String>> {
    let mut p = BTreeMap::new();
    p.insert("id".into(), DataValue::from(id));
    // Pull the caller's doc field too so we can surface provenance
    // (@scip-verified / @scip-only) alongside the qname.
    let r = storage.run_with_immutable(
        "?[qname, doc] := *Calls{src, dst: $id}, *Symbol{id: src, qname, doc}",
        p,
    )?;
    let mut out: Vec<String> = Vec::new();
    for row in &r.rows {
        let Some(qname) = row.first().and_then(ds) else {
            continue;
        };
        let doc = row.get(1).and_then(ds).unwrap_or_default();
        let mut tags: Vec<&str> = Vec::new();
        if doc.contains("@scip-verified") {
            tags.push("scip-verified");
        }
        if doc.contains("@scip-only") {
            tags.push("scip-only");
        }
        if tags.is_empty() {
            out.push(qname);
        } else {
            out.push(format!("{qname}  [{}]", tags.join(",")));
        }
    }
    out.sort();
    out.dedup();
    Ok(out)
}

fn routes_for(storage: &ckg_storage::Storage, qname: &str) -> Result<Vec<String>> {
    let needle = format!("handler={qname}");
    let mut p = BTreeMap::new();
    p.insert("n".into(), DataValue::from(needle.as_str()));
    let r = storage.run_with_immutable(
        "?[rqname] := *Symbol{qname: rqname, kind: \"route\", doc}, str_includes(doc, $n)",
        p,
    )?;
    Ok(collect_strs(&r.rows, 0))
}

fn tests_for(storage: &ckg_storage::Storage, id: &str) -> Result<Vec<String>> {
    let mut p = BTreeMap::new();
    p.insert("id".into(), DataValue::from(id));
    let r = storage.run_with_immutable(
        "?[qname] := *Tests{src, dst: $id}, *Symbol{id: src, qname}",
        p,
    )?;
    Ok(collect_strs(&r.rows, 0))
}

fn ds(v: &DataValue) -> Option<String> {
    match v {
        DataValue::Str(s) => Some(s.to_string()),
        _ => None,
    }
}

fn collect_strs(rows: &[Vec<DataValue>], col: usize) -> Vec<String> {
    let mut out: Vec<String> = rows.iter().filter_map(|r| ds(r.get(col)?)).collect();
    out.sort();
    out.dedup();
    out
}

#[derive(Debug)]
struct PrMeta {
    title: String,
    url: String,
    base_ref_name: String,
    head_ref_name: String,
}

fn gh_pr_meta(pr: u32, repo: Option<&str>) -> Result<PrMeta> {
    let pr_str = pr.to_string();
    let mut args: Vec<&str> = vec!["pr", "view", &pr_str, "--json", "title,url,baseRefName,headRefName"];
    if let Some(r) = repo {
        args.extend(["-R", r]);
    }
    let out = run_gh(&args)?;
    let v: serde_json::Value =
        serde_json::from_str(&out).context("parsing `gh pr view` JSON")?;
    let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
    Ok(PrMeta {
        title: s("title"),
        url: s("url"),
        base_ref_name: s("baseRefName"),
        head_ref_name: s("headRefName"),
    })
}

fn gh_pr_diff(pr: u32, repo: Option<&str>) -> Result<String> {
    let pr_str = pr.to_string();
    let mut args: Vec<&str> = vec!["pr", "diff", &pr_str];
    if let Some(r) = repo {
        args.extend(["-R", r]);
    }
    run_gh(&args)
}

fn run_gh(args: &[&str]) -> Result<String> {
    let out = Command::new("gh")
        .args(args)
        .output()
        .map_err(|e| anyhow!("failed to invoke `gh` (install: https://cli.github.com): {e}"))?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        bail!("gh {} failed: {stderr}", args.join(" "));
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

fn print_human(pr: u32, meta: &PrMeta, files: usize, symbols: &[ChangedSymbol], with_callers: bool) {
    println!(
        "PR #{pr} — {title}\n  {url}\n  {base} ← {head}\n  {files} file(s) changed, {nsym} symbol(s) touched\n",
        title = meta.title,
        url = meta.url,
        base = meta.base_ref_name,
        head = meta.head_ref_name,
        nsym = symbols.len(),
    );
    if symbols.is_empty() {
        println!("(no enclosing symbols matched — likely whitespace / comment / new-file changes)");
        return;
    }
    for s in symbols {
        let mut tags: Vec<String> = Vec::new();
        if !s.routes.is_empty() {
            tags.push(format!("route×{}", s.routes.len()));
        }
        if !s.tests.is_empty() {
            tags.push(format!("test×{}", s.tests.len()));
        }
        if with_callers {
            tags.push(format!("callers×{}", s.callers.len()));
        }
        let tag_str = if tags.is_empty() { String::new() } else { format!("  [{}]", tags.join(" ")) };
        println!("● {} {} ({}:{}){tag_str}", s.kind, s.qname, s.file, s.line);
        for r in &s.routes {
            println!("    route   {r}");
        }
        for t in &s.tests {
            println!("    test    {t}");
        }
        if with_callers {
            for c in &s.callers {
                println!("    caller  {c}");
            }
        }
    }
}
