//! Impact analysis: find the most impactful fix categories for ground truth.
//!
//! Runs the lineage engine on all ~19,875 ground truth cases, classifies
//! each per-column mismatch into a root cause category, and computes how
//! many queries would become exact matches if that category were fixed.
//!
//! Run:  cargo run --example impact_analysis --release

use altimate_core::lineage::column_lineage;
use altimate_core::schema::types::SqlDialect;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::io::BufRead;
use std::path::Path;

// ---------------------------------------------------------------------------
// Ground truth case
// ---------------------------------------------------------------------------

#[derive(Deserialize)]
struct Case {
    name: String,
    sql: String,
    schema: serde_json::Value,
    expected: HashMap<String, Vec<String>>,
}

// ---------------------------------------------------------------------------
// Root cause categories
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum RootCause {
    RowNumberExtra,
    LateralFlattenExtra,
    MissingExpressionAlias,
    MissingStarExpansion,
    MissingConcatObjConstruct,
    ExtraCaseCondition,
    UnionCteMismatch,
    InsertPositionalRename,
    Other,
}

impl RootCause {
    fn label(self) -> &'static str {
        match self {
            Self::RowNumberExtra => "row_number_extra",
            Self::LateralFlattenExtra => "lateral_flatten_extra",
            Self::MissingExpressionAlias => "missing_expression_alias",
            Self::MissingStarExpansion => "missing_star_expansion",
            Self::MissingConcatObjConstruct => "missing_concat_obj_construct",
            Self::ExtraCaseCondition => "extra_case_condition",
            Self::UnionCteMismatch => "union_cte_mismatch",
            Self::InsertPositionalRename => "insert_positional_rename",
            Self::Other => "other",
        }
    }

    fn description(self) -> &'static str {
        match self {
            Self::RowNumberExtra => {
                "Extra output columns from ROW_NUMBER/RANK/DENSE_RANK window functions"
            }
            Self::LateralFlattenExtra => {
                "Extra columns traced to FLATTEN sources (VALUE, KEY, INDEX)"
            }
            Self::MissingExpressionAlias => {
                "Missing: expected traces through expression (REPLACE, CAST, etc.)"
            }
            Self::MissingStarExpansion => {
                "Missing: SELECT * not expanded (column exists in schema)"
            }
            Self::MissingConcatObjConstruct => {
                "Missing sources from CONCAT/OBJECT_CONSTRUCT/ARRAY_AGG/TO_JSON"
            }
            Self::ExtraCaseCondition => "Extra sources from CASE WHEN condition (not value branch)",
            Self::UnionCteMismatch => "UNION/UNION ALL with CTEs: source table resolution mismatch",
            Self::InsertPositionalRename => {
                "Missing column from INSERT INTO positional column mapping"
            }
            Self::Other => "Other / uncategorized",
        }
    }
}

// ---------------------------------------------------------------------------
// Per-column mismatch classification
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq)]
enum ColumnIssue {
    /// Column is EXTRA in actual output (not in expected)
    ExtraColumn { col: String, cause: RootCause },
    /// Column is MISSING from actual output (in expected but not actual)
    MissingColumn { col: String, cause: RootCause },
    /// Column exists in both but sources differ — extra sources in actual
    ExtraSources { col: String, cause: RootCause },
    /// Column exists in both but sources differ — missing sources in actual
    MissingSources { col: String, cause: RootCause },
}

impl ColumnIssue {
    fn cause(&self) -> RootCause {
        match self {
            Self::ExtraColumn { cause, .. } => *cause,
            Self::MissingColumn { cause, .. } => *cause,
            Self::ExtraSources { cause, .. } => *cause,
            Self::MissingSources { cause, .. } => *cause,
        }
    }
}

// ---------------------------------------------------------------------------
// Tracking
// ---------------------------------------------------------------------------

#[derive(Default)]
struct CategoryStats {
    columns_affected: u64,
    /// Count of queries where this root cause is the ONLY issue
    sole_fix_count: u64,
    /// Count of queries where this is one of multiple issues
    partial_fix_count: u64,
    /// Sample query names (sole fix), capped for display
    sole_fix_samples: Vec<String>,
    /// Sample query names (partial fix), capped for display
    partial_fix_samples: Vec<String>,
}

const SAMPLE_LIMIT: usize = 4;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn schema_from_json(value: &serde_json::Value) -> HashMap<String, serde_json::Value> {
    match value {
        serde_json::Value::Object(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
        _ => HashMap::new(),
    }
}

/// Normalize a column_dict: sort + dedup source lists.
fn normalize(map: &HashMap<String, Vec<String>>) -> HashMap<String, BTreeSet<String>> {
    map.iter()
        .map(|(k, v)| (k.clone(), v.iter().cloned().collect()))
        .collect()
}

/// Extract table name from a `"TABLE"."COLUMN"` reference.
fn table_of(ref_str: &str) -> String {
    let parts: Vec<&str> = ref_str.split('.').map(|p| p.trim_matches('"')).collect();
    if parts.len() >= 2 {
        parts[parts.len() - 2].to_uppercase()
    } else {
        String::new()
    }
}

/// Extract column name from a `"TABLE"."COLUMN"` reference.
fn col_of(ref_str: &str) -> String {
    let parts: Vec<&str> = ref_str.split('.').map(|p| p.trim_matches('"')).collect();
    parts.last().unwrap_or(&"").to_uppercase()
}

/// All schema column names (uppercased) from the schema JSON.
fn all_schema_columns(schema: &HashMap<String, serde_json::Value>) -> HashSet<String> {
    let mut cols = HashSet::new();
    for val in schema.values() {
        if let serde_json::Value::Object(obj) = val {
            for k in obj.keys() {
                cols.insert(k.to_uppercase());
            }
        }
    }
    cols
}

/// Check if SQL text contains a case-insensitive pattern.
fn sql_contains(sql: &str, pattern: &str) -> bool {
    sql.to_uppercase().contains(&pattern.to_uppercase())
}

/// Uppercased SQL for pattern matching.
fn sql_upper(sql: &str) -> String {
    sql.to_uppercase()
}

// ---------------------------------------------------------------------------
// Window function keywords for row_number_extra detection
// ---------------------------------------------------------------------------

const WINDOW_FUNCS: &[&str] = &[
    "ROW_NUMBER",
    "RANK",
    "DENSE_RANK",
    "NTILE",
    "CUME_DIST",
    "PERCENT_RANK",
];

/// Check if a column name or its sources suggest a window-function origin.
fn is_window_func_column(col_name: &str, sources: &BTreeSet<String>, sql_up: &str) -> bool {
    let col_up = col_name.to_uppercase();

    // Column name contains a window function name
    for wf in WINDOW_FUNCS {
        if col_up.contains(wf) {
            return true;
        }
    }

    // Common auto-generated names: RN, ROW_NUM, ROWNUM, RANK_NUM, DENSERANK
    let name_hints = ["RN", "ROW_NUM", "ROWNUM", "RANK_NUM", "DENSERANK", "RNUM"];
    for hint in name_hints {
        if col_up == hint || col_up.ends_with(&format!("_{hint}")) {
            // Verify the SQL actually uses a window function
            if WINDOW_FUNCS.iter().any(|wf| sql_up.contains(wf)) {
                return true;
            }
        }
    }

    // Sources empty and SQL has window function near the column name
    if sources.is_empty() && WINDOW_FUNCS.iter().any(|wf| sql_up.contains(wf)) {
        // Check if the column alias appears near a window function in the SQL
        for wf in WINDOW_FUNCS {
            let pat = format!("{wf}(");
            if let Some(pos) = sql_up.find(&pat) {
                // Look for the column alias within 200 chars after the window func
                let window = &sql_up[pos..std::cmp::min(pos + 200, sql_up.len())];
                if window.contains(&col_up) {
                    return true;
                }
            }
        }
    }

    false
}

// ---------------------------------------------------------------------------
// FLATTEN-related keywords
// ---------------------------------------------------------------------------

const FLATTEN_COLS: &[&str] = &["VALUE", "KEY", "INDEX", "SEQ", "THIS", "PATH"];

fn is_flatten_column(col_name: &str, sources: &BTreeSet<String>, sql_up: &str) -> bool {
    let col_up = col_name.to_uppercase();

    // Column name is a known FLATTEN pseudo-column
    if FLATTEN_COLS.contains(&col_up.as_str()) {
        return sql_up.contains("FLATTEN");
    }

    // Any source references a FLATTEN pseudo-column table
    for src in sources {
        let tbl = table_of(src);
        if tbl == "FLATTEN" || tbl.contains("FLATTEN") {
            return true;
        }
        let col = col_of(src);
        if FLATTEN_COLS.contains(&col.as_str()) && sql_up.contains("FLATTEN") {
            return true;
        }
    }

    // Column name contains FLATTEN-related terms
    if (col_up.contains("FLATTEN") || col_up.contains("LATERAL")) && sql_up.contains("FLATTEN") {
        return true;
    }

    false
}

// ---------------------------------------------------------------------------
// Expression function keywords (for missing_expression_alias)
// ---------------------------------------------------------------------------

const EXPR_FUNCS: &[&str] = &[
    "REPLACE",
    "SUBSTRING",
    "SUBSTR",
    "CONCAT",
    "CAST",
    "TRIM",
    "LTRIM",
    "RTRIM",
    "UPPER",
    "LOWER",
    "COALESCE",
    "NVL",
    "IFNULL",
    "NULLIF",
    "LEFT",
    "RIGHT",
    "LPAD",
    "RPAD",
    "SPLIT_PART",
    "REGEXP_REPLACE",
    "REGEXP_SUBSTR",
    "TO_CHAR",
    "TO_DATE",
    "TO_TIMESTAMP",
    "TO_NUMBER",
    "TO_VARCHAR",
    "TRY_CAST",
    "TRY_TO_NUMBER",
    "TRY_TO_DATE",
    "IFF",
    "DECODE",
    "DATE_TRUNC",
    "DATEADD",
    "DATEDIFF",
    "EXTRACT",
    "HASH",
    "MD5",
    "SHA1",
    "SHA2",
    "INITCAP",
    "TRANSLATE",
    "REVERSE",
    "LENGTH",
    "LEN",
    "CHARINDEX",
    "POSITION",
    "INSTR",
    "REPEAT",
    "SPACE",
    "PARSE_JSON",
    "STRTOK_TO_ARRAY",
    "ARRAY_CONSTRUCT",
    "OBJECT_CONSTRUCT",
    "ZEROIFNULL",
    "ABS",
    "ROUND",
    "CEIL",
    "FLOOR",
    "TRUNC",
    "MOD",
    "GREATEST",
    "LEAST",
    "SIGN",
];

fn sql_has_expression_func(sql_up: &str) -> bool {
    EXPR_FUNCS.iter().any(|f| {
        let pat = format!("{f}(");
        sql_up.contains(&pat)
    })
}

// ---------------------------------------------------------------------------
// Aggregation/construction functions (for missing_concat_obj_construct)
// ---------------------------------------------------------------------------

const AGG_CONSTRUCT_FUNCS: &[&str] = &[
    "CONCAT",
    "OBJECT_CONSTRUCT",
    "ARRAY_AGG",
    "TO_JSON",
    "PARSE_JSON",
    "ARRAY_CONSTRUCT",
    "OBJECT_AGG",
    "LISTAGG",
    "ARRAY_TO_STRING",
    "OBJECT_CONSTRUCT_KEEP_NULL",
    "TO_VARIANT",
];

fn is_agg_construct_source_issue(missing_sources: &[&String], sql_up: &str) -> bool {
    if missing_sources.is_empty() {
        return false;
    }
    // Check if SQL uses one of the aggregation/construction functions
    AGG_CONSTRUCT_FUNCS.iter().any(|f| {
        let pat = format!("{f}(");
        sql_up.contains(&pat)
    })
}

// ---------------------------------------------------------------------------
// CASE WHEN condition detection (for extra_case_condition)
// ---------------------------------------------------------------------------

fn is_extra_from_case_condition(extra_sources: &[&String], sql_up: &str) -> bool {
    if extra_sources.is_empty() || !sql_up.contains("CASE") {
        return false;
    }
    // Heuristic: for each extra source, check if the column name from the source
    // appears between WHEN and THEN (i.e., in a condition, not a value branch).
    // This is an approximation — true detection needs AST analysis.
    for src in extra_sources {
        let src_col = col_of(src);
        if src_col.is_empty() {
            continue;
        }
        // Find all WHEN...THEN spans and check if src_col appears there
        let mut found_in_condition = false;
        let mut search_from = 0;
        while let Some(when_pos) = sql_up[search_from..].find("WHEN ") {
            let abs_when = search_from + when_pos;
            if let Some(then_offset) = sql_up[abs_when..].find(" THEN ") {
                let condition_span = &sql_up[abs_when..abs_when + then_offset];
                if condition_span.contains(&src_col) {
                    found_in_condition = true;
                    break;
                }
                search_from = abs_when + then_offset + 6;
            } else {
                break;
            }
        }
        if found_in_condition {
            return true;
        }
    }
    false
}

// ---------------------------------------------------------------------------
// UNION + CTE detection
// ---------------------------------------------------------------------------

fn has_union_cte(sql_up: &str) -> bool {
    (sql_up.contains("UNION ALL") || sql_up.contains("UNION ")) && sql_up.contains("WITH ")
}

// ---------------------------------------------------------------------------
// INSERT positional rename detection
// ---------------------------------------------------------------------------

fn is_insert_positional_rename(col_name: &str, sql: &str) -> bool {
    let sql_up = sql.to_uppercase();
    let col_up = col_name.to_uppercase();

    // Column name doesn't appear anywhere in the SQL text
    // (it comes from INSERT INTO target_table (col1, col2, ...) mapping)
    if sql_up.contains(&col_up) {
        return false;
    }

    // And the SQL references INSERT INTO or has been identified as INSERT
    // (ground truth strips INSERT portion, but column names come from it)
    // Since ground truth has already stripped INSERT, we check if col doesn't
    // appear in the remaining SELECT at all
    true
}

// ---------------------------------------------------------------------------
// Classify per-column issues for a single query
// ---------------------------------------------------------------------------

fn classify_issues(
    actual: &HashMap<String, BTreeSet<String>>,
    expected: &HashMap<String, BTreeSet<String>>,
    sql: &str,
    schema: &HashMap<String, serde_json::Value>,
) -> Vec<ColumnIssue> {
    let sql_up = sql_upper(sql);
    let schema_cols = all_schema_columns(schema);
    let mut issues = Vec::new();

    // 1) Extra output columns: in actual but not in expected
    for (col, act_sources) in actual {
        if expected.contains_key(col) {
            continue;
        }

        let cause = if is_window_func_column(col, act_sources, &sql_up) {
            RootCause::RowNumberExtra
        } else if is_flatten_column(col, act_sources, &sql_up) {
            RootCause::LateralFlattenExtra
        } else {
            RootCause::Other
        };

        issues.push(ColumnIssue::ExtraColumn {
            col: col.clone(),
            cause,
        });
    }

    // 2) Missing output columns: in expected but not in actual
    for col in expected.keys() {
        if actual.contains_key(col) {
            continue;
        }

        let cause = if is_insert_positional_rename(col, sql) {
            RootCause::InsertPositionalRename
        } else if sql_contains(sql, "SELECT *")
            || sql_contains(sql, "SELECT A.*")
            || sql_contains(sql, "SELECT B.*")
            || sql_contains(sql, "SELECT T.*")
            || sql_contains(sql, "SELECT S.*")
        {
            // Check if the column exists in schema (star expansion failure)
            if schema_cols.contains(&col.to_uppercase()) {
                RootCause::MissingStarExpansion
            } else if sql_has_expression_func(&sql_up) {
                RootCause::MissingExpressionAlias
            } else {
                RootCause::Other
            }
        } else if sql_has_expression_func(&sql_up) {
            RootCause::MissingExpressionAlias
        } else {
            RootCause::Other
        };

        issues.push(ColumnIssue::MissingColumn {
            col: col.clone(),
            cause,
        });
    }

    // 3) Shared columns with different sources
    for (col, exp_sources) in expected {
        let act_sources = match actual.get(col) {
            Some(s) if s != exp_sources => s,
            _ => continue,
        };

        // Extra sources in actual (not in expected)
        let extra: Vec<&String> = act_sources.difference(exp_sources).collect();
        // Missing sources from actual (in expected but not actual)
        let missing: Vec<&String> = exp_sources.difference(act_sources).collect();

        // Classify extra sources
        if !extra.is_empty() {
            let cause = if is_extra_from_case_condition(&extra, &sql_up) {
                RootCause::ExtraCaseCondition
            } else if has_union_cte(&sql_up) {
                RootCause::UnionCteMismatch
            } else {
                RootCause::Other
            };
            issues.push(ColumnIssue::ExtraSources {
                col: col.clone(),
                cause,
            });
        }

        // Classify missing sources
        if !missing.is_empty() {
            let cause = if is_agg_construct_source_issue(&missing, &sql_up) {
                RootCause::MissingConcatObjConstruct
            } else if has_union_cte(&sql_up) {
                RootCause::UnionCteMismatch
            } else if sql_has_expression_func(&sql_up) {
                RootCause::MissingExpressionAlias
            } else {
                RootCause::Other
            };
            issues.push(ColumnIssue::MissingSources {
                col: col.clone(),
                cause,
            });
        }
    }

    issues
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

fn main() {
    let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../tests/fixtures/lineage/snowflake_ground_truth.jsonl");

    if !fixture_path.exists() {
        eprintln!("Ground-truth fixture not found: {}", fixture_path.display());
        eprintln!("Expected at tests/fixtures/lineage/snowflake_ground_truth.jsonl");
        std::process::exit(1);
    }

    let file = std::fs::File::open(&fixture_path).expect("failed to open fixture");
    let reader = std::io::BufReader::with_capacity(1024 * 1024, file);
    let dialect = SqlDialect::Snowflake;

    let mut total = 0u64;
    let mut exact_match = 0u64;
    let mut parse_fail = 0u64;
    let mut failing = 0u64;

    // Per root-cause stats
    let mut stats: BTreeMap<RootCause, CategoryStats> = BTreeMap::new();

    let start = std::time::Instant::now();

    for (line_idx, line) in reader.lines().enumerate() {
        let line = line.expect("failed to read line");
        let line = line.trim().to_string();
        if line.is_empty() {
            continue;
        }

        let case: Case = match serde_json::from_str(&line) {
            Ok(c) => c,
            Err(_) => continue,
        };

        total += 1;

        let schema = schema_from_json(&case.schema);
        let expected = normalize(&case.expected);

        let result = match column_lineage(&case.sql, dialect, &schema, None) {
            Ok(r) => r,
            Err(_) => {
                parse_fail += 1;
                continue;
            }
        };

        let actual = normalize(&result.column_dict);

        if actual == expected {
            exact_match += 1;
            continue;
        }

        failing += 1;

        // Classify all per-column issues
        let issues = classify_issues(&actual, &expected, &case.sql, &schema);

        if issues.is_empty() {
            // Edge case: maps differ but classify_issues found nothing specific
            let entry = stats.entry(RootCause::Other).or_default();
            entry.columns_affected += 1;
            entry.sole_fix_count += 1;
            if entry.sole_fix_samples.len() < SAMPLE_LIMIT {
                entry.sole_fix_samples.push(case.name.clone());
            }
            continue;
        }

        // Collect the distinct root causes for this query
        let causes_in_query: BTreeSet<RootCause> = issues.iter().map(|i| i.cause()).collect();

        // Update column counts
        for issue in &issues {
            let entry = stats.entry(issue.cause()).or_default();
            entry.columns_affected += 1;
        }

        // For each cause, determine if it's the sole issue or one of many
        for cause in &causes_in_query {
            let entry = stats.entry(*cause).or_default();
            if causes_in_query.len() == 1 {
                entry.sole_fix_count += 1;
                if entry.sole_fix_samples.len() < SAMPLE_LIMIT {
                    entry.sole_fix_samples.push(case.name.clone());
                }
            } else {
                entry.partial_fix_count += 1;
                if entry.partial_fix_samples.len() < SAMPLE_LIMIT {
                    entry.partial_fix_samples.push(case.name.clone());
                }
            }
        }

        if (line_idx + 1) % 2000 == 0 {
            eprint!("\r  processed {} / ~19875 ...", line_idx + 1);
        }
    }

    let elapsed = start.elapsed();
    eprintln!(
        "\r  done. {} queries in {:.1}s                      ",
        total,
        elapsed.as_secs_f64()
    );

    // -----------------------------------------------------------------------
    // Report
    // -----------------------------------------------------------------------

    println!();
    println!("====================================================================");
    println!("  IMPACT ANALYSIS — Snowflake Ground Truth Column Lineage");
    println!("====================================================================");
    println!();
    println!("  Total queries:      {:>7}", total);
    println!(
        "  Exact match:        {:>7}  ({:.1}%)",
        exact_match,
        exact_match as f64 / total as f64 * 100.0
    );
    println!(
        "  Parse fail:         {:>7}  ({:.1}%)",
        parse_fail,
        parse_fail as f64 / total as f64 * 100.0
    );
    println!(
        "  Failing (analyzed): {:>7}  ({:.1}%)",
        failing,
        failing as f64 / total as f64 * 100.0
    );

    // -----------------------------------------------------------------------
    // Per-category detail
    // -----------------------------------------------------------------------

    println!();
    println!("--------------------------------------------------------------------");
    println!("  PER ROOT-CAUSE CATEGORY DETAIL");
    println!("--------------------------------------------------------------------");

    let all_causes = [
        RootCause::RowNumberExtra,
        RootCause::LateralFlattenExtra,
        RootCause::MissingExpressionAlias,
        RootCause::MissingStarExpansion,
        RootCause::MissingConcatObjConstruct,
        RootCause::ExtraCaseCondition,
        RootCause::UnionCteMismatch,
        RootCause::InsertPositionalRename,
        RootCause::Other,
    ];

    for cause in &all_causes {
        let default = CategoryStats::default();
        let entry = stats.get(cause).unwrap_or(&default);

        let sole = entry.sole_fix_count;
        let partial = entry.partial_fix_count;

        println!();
        println!("  {} ({})", cause.label(), cause.description());
        println!(
            "    Columns affected:           {:>7}",
            entry.columns_affected
        );
        println!(
            "    Queries fixed alone:        {:>7}  (fixing = exact match)",
            sole
        );
        println!(
            "    Queries partially improved: {:>7}  (one of multiple issues)",
            partial
        );

        // Sample query names (up to 2)
        if !entry.sole_fix_samples.is_empty() {
            println!("    Sample (sole fix):");
            for name in entry.sole_fix_samples.iter().take(2) {
                println!("      - {name}");
            }
        }
        if !entry.partial_fix_samples.is_empty() {
            println!("    Sample (partial):");
            for name in entry.partial_fix_samples.iter().take(2) {
                println!("      - {name}");
            }
        }
    }

    // -----------------------------------------------------------------------
    // Priority table: sorted by (sole + partial) descending
    // -----------------------------------------------------------------------

    println!();
    println!("====================================================================");
    println!("  FIX PRIORITY TABLE (sorted by total query impact descending)");
    println!("====================================================================");
    println!();
    println!(
        "  {:<30} {:>8} {:>10} {:>10} {:>10}",
        "Root Cause", "Columns", "Sole Fix", "Partial", "Total"
    );
    println!(
        "  {:-<30} {:-<8} {:-<10} {:-<10} {:-<10}",
        "", "", "", "", ""
    );

    // Build sorted list
    let mut priority: Vec<(RootCause, u64, u64, u64)> = all_causes
        .iter()
        .map(|cause| {
            let entry = stats.get(cause);
            let cols = entry.map_or(0, |e| e.columns_affected);
            let sole = entry.map_or(0, |e| e.sole_fix_count);
            let partial = entry.map_or(0, |e| e.partial_fix_count);
            (*cause, cols, sole, partial)
        })
        .collect();

    priority.sort_by(|a, b| (b.2 + b.3).cmp(&(a.2 + a.3)));

    for (cause, cols, sole, partial) in &priority {
        println!(
            "  {:<30} {:>8} {:>10} {:>10} {:>10}",
            cause.label(),
            cols,
            sole,
            partial,
            sole + partial,
        );
    }

    // -----------------------------------------------------------------------
    // What-if analysis
    // -----------------------------------------------------------------------

    println!();
    println!("--------------------------------------------------------------------");
    println!("  WHAT-IF: Projected exact-match rate after fixing each category");
    println!("--------------------------------------------------------------------");
    println!();

    let current_rate = exact_match as f64 / total as f64 * 100.0;
    println!("  Current exact match: {exact_match}/{total} ({current_rate:.1}%)");
    println!();

    for (cause, _cols, sole, _partial) in &priority {
        if *sole == 0 {
            continue;
        }
        let new_exact = exact_match + *sole;
        let new_rate = new_exact as f64 / total as f64 * 100.0;
        let delta = new_rate - current_rate;
        println!(
            "  + fix {:<28} => {}/{} ({:.1}%, +{:.1}pp)",
            cause.label(),
            new_exact,
            total,
            new_rate,
            delta,
        );
    }

    // Cumulative: fix all categories in priority order
    println!();
    println!("  Cumulative (fix all in priority order):");
    let mut cumulative_exact = exact_match;
    for (cause, _cols, sole, _partial) in &priority {
        if *sole == 0 {
            continue;
        }
        cumulative_exact += *sole;
        let rate = cumulative_exact as f64 / total as f64 * 100.0;
        println!(
            "    + {:<28} => {}/{} ({:.1}%)",
            cause.label(),
            cumulative_exact,
            total,
            rate,
        );
    }

    println!();
    println!("====================================================================");
    println!("  Done in {:.1}s", elapsed.as_secs_f64());
    println!("====================================================================");
}
