//! Tentacle 2 calibration harness.
//!
//! Gated integration test. Runs `cold_build` on a list of real repos
//! and emits markdown-formatted statistics suitable for pasting into
//! `docs/research/coupling-calibration-2026-04-18.md`. Does NOT ship
//! as product code — it exists purely to generate evidence for the
//! ADR's default coefficients.
//!
//! Portable CI/manual smoke:
//!   cargo test --release --test coupling_calibration calibrate_current_repo_smoke -- --ignored --nocapture
//!
//! Full operator corpus:
//!   SYMFORGE_CALIBRATION_REPOS="symforge=/path/to/symforge;tokio=/path/to/tokio;magika=/path/to/magika" \
//!   cargo test --release --test coupling_calibration calibrate_against_real_repos -- --ignored --nocapture
//!
//! Legacy local run shape:
//!   cargo test --release --test coupling_calibration -- --ignored --nocapture

use std::env;
use std::path::{Path, PathBuf};
use std::time::Instant;

use rusqlite::Connection;
use symforge::live_index::coupling::{CouplingStore, WalkerConfig, cold_build};

#[test]
#[ignore]
fn calibrate_against_real_repos() {
    let targets = calibration_targets_from_env();
    assert!(
        targets.len() >= 3,
        "full calibration requires SYMFORGE_CALIBRATION_REPOS with at least \
         three name=path entries, for example \
         symforge=/repos/symforge;tokio=/repos/tokio;magika=/repos/magika"
    );

    println!("\n\n# Coupling calibration run\n");
    println!(
        "Generated by `tests/coupling_calibration.rs`. Defaults: \
         max_commits={}, window_days={}, half_life_days={}, max_files_per_commit={}.\n",
        symforge::live_index::coupling::walker::DEFAULT_MAX_COMMITS,
        symforge::live_index::coupling::walker::DEFAULT_WINDOW_DAYS,
        symforge::live_index::coupling::walker::DEFAULT_HALF_LIFE_DAYS,
        symforge::live_index::coupling::walker::DEFAULT_MAX_FILES_PER_COMMIT,
    );

    // Pin corpus state: record each repo's HEAD SHA + bail if the corpus
    // is smaller than the Tentacle 2 brief requires (3 repos). Silently
    // under-sampling would weaken the ADR's auditability.
    let mut present: Vec<(&str, &Path, String)> = Vec::new();
    let mut missing: Vec<&str> = Vec::new();
    for (name, repo_path) in &targets {
        match read_head_sha(repo_path) {
            Some(sha) => present.push((name, repo_path.as_path(), sha)),
            None => missing.push(name),
        }
    }

    println!("## Corpus pin\n");
    println!("| repo | path | HEAD SHA |");
    println!("|---|---|---|");
    for (name, path, sha) in &present {
        println!("| {name} | `{}` | `{sha}` |", path.display());
    }
    for name in &missing {
        println!("| {name} | — | _missing_ |");
    }
    println!();

    assert!(
        present.len() >= 3,
        "calibration requires all 3 corpus repos; missing: {missing:?}. \
         Clone them and retry before shipping ADR evidence."
    );

    for (name, repo_path, _sha) in &present {
        for include_symbols in [false, true] {
            run_calibration(name, repo_path, include_symbols);
        }
    }
}

#[test]
#[ignore]
fn calibrate_current_repo_smoke() {
    let repo_path = Path::new(env!("CARGO_MANIFEST_DIR"));
    let mut cfg = WalkerConfig::system_now();
    cfg.max_commits = 50;
    cfg.window_days = 365;
    cfg.include_symbols = false;

    run_calibration_with_config("symforge-ci-smoke", repo_path, cfg);
}

fn calibration_targets_from_env() -> Vec<(String, PathBuf)> {
    let Ok(raw) = env::var("SYMFORGE_CALIBRATION_REPOS") else {
        return Vec::new();
    };
    raw.split(';')
        .filter_map(|entry| {
            let entry = entry.trim();
            if entry.is_empty() {
                return None;
            }
            let (name, path) = entry.split_once('=').unwrap_or_else(|| {
                panic!(
                    "invalid SYMFORGE_CALIBRATION_REPOS entry `{entry}`; expected name=/repo/path"
                )
            });
            Some((name.trim().to_string(), PathBuf::from(path.trim())))
        })
        .collect()
}

fn read_head_sha(repo_path: &Path) -> Option<String> {
    if !repo_path.exists() {
        return None;
    }
    let repo = git2::Repository::open(repo_path).ok()?;
    let head = repo.head().ok()?;
    let oid = head.target()?;
    Some(oid.to_string())
}

fn run_calibration(name: &str, repo_path: &Path, include_symbols: bool) {
    let mut cfg = WalkerConfig::system_now();
    cfg.include_symbols = include_symbols;
    run_calibration_with_config(name, repo_path, cfg);
}

fn run_calibration_with_config(name: &str, repo_path: &Path, cfg: WalkerConfig) {
    let mode = if cfg.include_symbols {
        "file+symbol"
    } else {
        "file-only"
    };
    println!("## {name} ({mode})\n");
    println!("- **Repo path:** `{}`", repo_path.display());

    let tmp = tempfile::tempdir().unwrap();
    let db_path = tmp.path().join("coupling.db");

    let stats;
    let build_time_ms;
    {
        let store = CouplingStore::open(&db_path).unwrap();
        let t0 = Instant::now();
        stats = match cold_build(&store, repo_path, &cfg) {
            Ok(s) => s,
            Err(e) => {
                println!("- **cold_build failed:** `{e}`\n");
                return;
            }
        };
        build_time_ms = t0.elapsed().as_millis();
    }

    println!("- **Build time:** `{build_time_ms} ms`");
    println!("- **Walker stats:**");
    println!("  - commits_scanned: `{}`", stats.commits_scanned);
    println!("  - commits_with_pairs: `{}`", stats.commits_with_pairs);
    println!(
        "  - commits_skipped_large: `{}`",
        stats.commits_skipped_large
    );
    println!("  - unique_edges: `{}`", stats.unique_edges);
    println!("  - rows_written: `{}`", stats.rows_written);
    println!();

    let conn = match Connection::open(&db_path) {
        Ok(c) => c,
        Err(e) => {
            println!("- read connection failed: `{e}`\n");
            return;
        }
    };

    dump_anchor_distribution(&conn);
    dump_score_distribution(&conn);
    dump_hotspot_anchors(&conn);
    dump_top_pairs(&conn);
    dump_weak_anchor_signal(&conn);

    println!();
}

fn dump_anchor_distribution(conn: &Connection) {
    println!("### Partner-count distribution per anchor (directed edges)\n");

    // partner-count per anchor = number of distinct partners
    let mut stmt = conn
        .prepare(
            "SELECT cnt, COUNT(*) FROM (
                 SELECT anchor_key, COUNT(*) AS cnt
                 FROM coupling GROUP BY anchor_key
             ) GROUP BY cnt ORDER BY cnt",
        )
        .unwrap();
    let counts: Vec<(u32, u32)> = stmt
        .query_map([], |r| Ok((r.get::<_, u32>(0)?, r.get::<_, u32>(1)?)))
        .unwrap()
        .filter_map(|r| r.ok())
        .collect();

    if counts.is_empty() {
        println!("_no rows_\n");
        return;
    }

    let total: u32 = counts.iter().map(|(_, n)| *n).sum();

    let mut buckets: Vec<(&str, u32)> = vec![
        ("1", 0),
        ("2–5", 0),
        ("6–20", 0),
        ("21–100", 0),
        (">100", 0),
    ];
    for (cnt, n) in &counts {
        let idx = if *cnt == 1 {
            0
        } else if *cnt <= 5 {
            1
        } else if *cnt <= 20 {
            2
        } else if *cnt <= 100 {
            3
        } else {
            4
        };
        buckets[idx].1 += n;
    }
    println!("| partner count | anchors | share |");
    println!("|---|---:|---:|");
    for (label, n) in &buckets {
        let pct = 100.0 * (*n as f64) / (total as f64);
        println!("| {label} | {n} | {pct:.1}% |");
    }
    println!();
}

fn dump_score_distribution(conn: &Connection) {
    println!("### Score distribution (all coupling rows)\n");

    let shared: Vec<u32> = conn
        .prepare("SELECT shared_commits FROM coupling ORDER BY shared_commits")
        .unwrap()
        .query_map([], |r| r.get::<_, u32>(0))
        .unwrap()
        .filter_map(|r| r.ok())
        .collect();
    let scores: Vec<f64> = conn
        .prepare("SELECT weighted_score FROM coupling ORDER BY weighted_score")
        .unwrap()
        .query_map([], |r| r.get::<_, f64>(0))
        .unwrap()
        .filter_map(|r| r.ok())
        .collect();

    if shared.is_empty() {
        println!("_no rows_\n");
        return;
    }

    fn pct_u32(v: &[u32], p: f64) -> u32 {
        let idx = ((v.len() as f64 - 1.0) * p).round() as usize;
        v[idx]
    }
    fn pct_f64(v: &[f64], p: f64) -> f64 {
        let idx = ((v.len() as f64 - 1.0) * p).round() as usize;
        v[idx]
    }

    println!("| percentile | shared_commits | weighted_score |");
    println!("|---|---:|---:|");
    for p in [0.5, 0.75, 0.90, 0.95, 0.99, 0.999] {
        let s = pct_u32(&shared, p);
        let w = pct_f64(&scores, p);
        println!("| p{:.1} | {s} | {w:.4} |", p * 100.0);
    }
    let max_s = shared.last().copied().unwrap_or(0);
    let max_w = scores.last().copied().unwrap_or(0.0);
    println!("| max | {max_s} | {max_w:.4} |");
    println!();
}

fn dump_hotspot_anchors(conn: &Connection) {
    println!("### Top 10 hotspot anchors (by partner count)\n");
    let mut stmt = conn
        .prepare(
            "SELECT anchor_key, COUNT(*) AS cnt, MAX(weighted_score) AS max_score
             FROM coupling GROUP BY anchor_key ORDER BY cnt DESC LIMIT 10",
        )
        .unwrap();
    let rows: Vec<(String, u32, f64)> = stmt
        .query_map([], |r| {
            Ok((
                r.get::<_, String>(0)?,
                r.get::<_, u32>(1)?,
                r.get::<_, f64>(2)?,
            ))
        })
        .unwrap()
        .filter_map(|r| r.ok())
        .collect();
    if rows.is_empty() {
        println!("_no rows_\n");
        return;
    }
    println!("| anchor | partners | max score |");
    println!("|---|---:|---:|");
    for (a, cnt, ms) in rows {
        println!("| `{}` | {cnt} | {ms:.4} |", truncate(&a, 60));
    }
    println!();
}

fn dump_top_pairs(conn: &Connection) {
    println!("### Top 10 pairs (by weighted_score)\n");
    let mut stmt = conn
        .prepare(
            "SELECT anchor_key, partner_key, shared_commits, weighted_score
             FROM coupling ORDER BY weighted_score DESC LIMIT 10",
        )
        .unwrap();
    let rows: Vec<(String, String, u32, f64)> = stmt
        .query_map([], |r| {
            Ok((
                r.get::<_, String>(0)?,
                r.get::<_, String>(1)?,
                r.get::<_, u32>(2)?,
                r.get::<_, f64>(3)?,
            ))
        })
        .unwrap()
        .filter_map(|r| r.ok())
        .collect();
    if rows.is_empty() {
        println!("_no rows_\n");
        return;
    }
    println!("| anchor | partner | shared | score |");
    println!("|---|---|---:|---:|");
    for (a, p, s, w) in rows {
        println!(
            "| `{}` | `{}` | {s} | {w:.4} |",
            truncate(&a, 50),
            truncate(&p, 50)
        );
    }
    println!();
}

fn dump_weak_anchor_signal(conn: &Connection) {
    println!("### Weak-anchor signal (candidates for `anchor_confidence_gate`)\n");

    // Fraction of all rows whose weighted_score is below percentile X of
    // the distribution. This informs a "below this threshold → drop the
    // rerank" policy.
    let total: u32 = conn
        .query_row("SELECT COUNT(*) FROM coupling", [], |r| r.get::<_, u32>(0))
        .unwrap_or(0);
    if total == 0 {
        println!("_no rows_\n");
        return;
    }

    let quartiles: Vec<f64> = {
        let mut scores: Vec<f64> = conn
            .prepare("SELECT weighted_score FROM coupling ORDER BY weighted_score")
            .unwrap()
            .query_map([], |r| r.get::<_, f64>(0))
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();
        scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
        [0.25, 0.5, 0.75]
            .iter()
            .map(|p| {
                let idx = ((scores.len() as f64 - 1.0) * p).round() as usize;
                scores[idx]
            })
            .collect()
    };

    let q1 = quartiles[0];
    let q2 = quartiles[1];
    let q3 = quartiles[2];

    let below_q1: u32 = conn
        .query_row(
            "SELECT COUNT(*) FROM coupling WHERE weighted_score <= ?1",
            rusqlite::params![q1],
            |r| r.get::<_, u32>(0),
        )
        .unwrap_or(0);
    let shared_eq_1: u32 = conn
        .query_row(
            "SELECT COUNT(*) FROM coupling WHERE shared_commits = 1",
            [],
            |r| r.get::<_, u32>(0),
        )
        .unwrap_or(0);

    println!("- q1 (25th pct) score: `{q1:.4}` — {below_q1} rows at or below");
    println!("- q2 (median): `{q2:.4}`");
    println!("- q3 (75th pct): `{q3:.4}`");
    println!(
        "- rows with `shared_commits = 1`: `{shared_eq_1}` ({:.1}%) — candidate for \"at least 2 co-changes to count\" threshold",
        100.0 * (shared_eq_1 as f64) / (total as f64)
    );
    println!();
}

fn truncate(s: &str, n: usize) -> String {
    if s.len() <= n {
        s.to_string()
    } else {
        format!("{}…", &s[..n])
    }
}

#[allow(dead_code)]
fn fixtures_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
