//! Deep dive on the "subset sources" failure pattern.
//!
//! Focuses on cases where we produce ALL the right output columns but are
//! MISSING some expected source references.  For each missing source we
//! inspect the raw SQL to classify *where* that column actually appears:
//!
//!   a) WITHIN GROUP (ORDER BY ...)
//!   b) GROUP BY
//!   c) CONCAT(...) / string concatenation (||)
//!   d) Variant/JSON access (: operator)
//!   e) OBJECT_CONSTRUCT(...) / ARRAY_AGG(...)
//!   f) Window function OVER (PARTITION BY ... ORDER BY ...)
//!   g) WHERE / HAVING / JOIN ON (filter-only — not in SELECT)
//!   h) LISTAGG(...) / STRING_AGG(...)
//!   i) INSERT INTO ... SELECT (positional column mapping)
//!   j) Other
//!
//! For each category:  count of missing refs, count of queries affected,
//! and 2 sample cases showing the SQL snippet where the missing column
//! appears.
//!
//! Run:  cargo run --example subset_deep_dive --release

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

// ===========================================================================
// Fixture type
// ===========================================================================

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

// ===========================================================================
// Category enum
// ===========================================================================

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum MissingCategory {
    WithinGroup,
    GroupBy,
    Concat,
    VariantAccess,
    ObjectConstructOrArrayAgg,
    WindowFunction,
    FilterOnly,
    ListaggOrStringAgg,
    InsertPositional,
    Other,
}

impl std::fmt::Display for MissingCategory {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::WithinGroup => write!(f, "(a) WITHIN GROUP (ORDER BY ...)"),
            Self::GroupBy => write!(f, "(b) GROUP BY"),
            Self::Concat => write!(f, "(c) CONCAT / || concatenation"),
            Self::VariantAccess => write!(f, "(d) Variant/JSON access (: operator)"),
            Self::ObjectConstructOrArrayAgg => {
                write!(f, "(e) OBJECT_CONSTRUCT / ARRAY_AGG")
            }
            Self::WindowFunction => write!(f, "(f) Window OVER (PARTITION/ORDER BY)"),
            Self::FilterOnly => write!(f, "(g) WHERE / HAVING / JOIN ON"),
            Self::ListaggOrStringAgg => write!(f, "(h) LISTAGG / STRING_AGG"),
            Self::InsertPositional => write!(f, "(i) INSERT INTO ... SELECT"),
            Self::Other => write!(f, "(j) Other"),
        }
    }
}

// ===========================================================================
// Sample case storage
// ===========================================================================

#[derive(Clone)]
struct SampleCase {
    query_name: String,
    output_col: String,
    missing_ref: String,
    sql_snippet: String,
}

// ===========================================================================
// 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(),
    }
}

fn normalize(map: &HashMap<String, Vec<String>>) -> HashMap<String, BTreeSet<String>> {
    map.iter()
        .map(|(k, v)| (k.clone(), v.iter().cloned().collect()))
        .collect()
}

fn normalize_to_short(map: &HashMap<String, Vec<String>>) -> HashMap<String, BTreeSet<String>> {
    map.iter()
        .map(|(k, v)| {
            let short: BTreeSet<String> = v
                .iter()
                .map(|ref_str| {
                    let parts: Vec<&str> =
                        ref_str.split('.').map(|p| p.trim_matches('"')).collect();
                    if parts.len() >= 3 {
                        let table = parts[parts.len() - 2];
                        let col = parts[parts.len() - 1];
                        format!("\"{table}\".\"{col}\"")
                    } else {
                        ref_str.clone()
                    }
                })
                .collect();
            (k.clone(), short)
        })
        .collect()
}

/// Extract the column portion from `"TABLE"."COL"` (uppercased).
fn col_name(ref_str: &str) -> String {
    let parts: Vec<&str> = ref_str.split('.').map(|p| p.trim_matches('"')).collect();
    parts.last().unwrap_or(&"").to_uppercase()
}

/// Find ALL byte-offset positions of `needle` in `haystack` (case-insensitive,
/// word-boundary aware).  Returns positions in the uppercased haystack.
fn find_word_positions(haystack_upper: &str, needle_upper: &str) -> Vec<usize> {
    let mut positions = Vec::new();
    let bytes = haystack_upper.as_bytes();
    let needle_len = needle_upper.len();
    let mut search_from = 0;

    while let Some(rel) = haystack_upper[search_from..].find(needle_upper) {
        let pos = search_from + rel;
        let before_ok = pos == 0 || {
            let b = bytes[pos - 1];
            !b.is_ascii_alphanumeric() && b != b'_'
        };
        let after_pos = pos + needle_len;
        let after_ok = after_pos >= bytes.len() || {
            let b = bytes[after_pos];
            !b.is_ascii_alphanumeric() && b != b'_'
        };
        if before_ok && after_ok {
            positions.push(pos);
        }
        search_from = pos + 1;
    }
    positions
}

/// Extract a context snippet around a byte position (up to `radius` chars
/// each side), collapsing whitespace for readability.
fn snippet_around(sql: &str, pos: usize, radius: usize) -> String {
    let start = pos.saturating_sub(radius);
    let end = (pos + radius).min(sql.len());
    let raw = &sql[start..end];
    // Collapse runs of whitespace into a single space
    let collapsed: String = raw.split_whitespace().collect::<Vec<_>>().join(" ");
    if start > 0 {
        format!("...{collapsed}...")
    } else {
        format!("{collapsed}...")
    }
}

// ===========================================================================
// Clause detection — precise regex + positional checks
// ===========================================================================

/// Build a word-boundary regex for a column name.
fn col_word_regex(col_upper: &str) -> Regex {
    // Escape any regex-special characters in the column name
    let escaped = regex::escape(col_upper);
    Regex::new(&format!(r"(?i)\b{escaped}\b")).unwrap()
}

/// Check if column appears inside a WITHIN GROUP (ORDER BY ...) clause.
fn in_within_group(sql_upper: &str, col_upper: &str) -> Option<usize> {
    // Pattern: WITHIN GROUP ( ORDER BY ... col ... )
    let re = Regex::new(r"WITHIN\s+GROUP\s*\(\s*ORDER\s+BY\b").unwrap();
    for m in re.find_iter(sql_upper) {
        let start = m.start();
        // Find the matching closing paren
        if let Some(close) = find_matching_paren(sql_upper, m.end() - 1) {
            let clause = &sql_upper[start..=close];
            let col_re = col_word_regex(col_upper);
            if col_re.is_match(clause) {
                return Some(start);
            }
        }
    }
    None
}

/// Check if column appears inside a GROUP BY clause.
fn in_group_by(sql_upper: &str, col_upper: &str) -> Option<usize> {
    let re = Regex::new(r"\bGROUP\s+BY\b").unwrap();
    let col_re = col_word_regex(col_upper);
    for m in re.find_iter(sql_upper) {
        let gb_start = m.start();
        // GROUP BY extends until HAVING, ORDER BY, LIMIT, QUALIFY, window, ), or end
        let rest = &sql_upper[m.end()..];
        let end_re =
            Regex::new(r"(?i)\b(?:HAVING|ORDER\s+BY|LIMIT|QUALIFY|UNION|INTERSECT|EXCEPT|FETCH)\b")
                .unwrap();
        let clause_end = end_re
            .find(rest)
            .map(|em| m.end() + em.start())
            .unwrap_or(sql_upper.len());
        let clause = &sql_upper[gb_start..clause_end];
        if col_re.is_match(clause) {
            return Some(gb_start);
        }
    }
    None
}

/// Check if column appears inside CONCAT(...) or string concatenation (||).
fn in_concat(sql_upper: &str, col_upper: &str) -> Option<usize> {
    let col_re = col_word_regex(col_upper);
    // Check CONCAT(...)
    let concat_re = Regex::new(r"\bCONCAT\s*\(").unwrap();
    for m in concat_re.find_iter(sql_upper) {
        let open = m.end() - 1; // position of '('
        if let Some(close) = find_matching_paren(sql_upper, open) {
            let clause = &sql_upper[m.start()..=close];
            if col_re.is_match(clause) {
                return Some(m.start());
            }
        }
    }
    // Check || concatenation: col appears adjacent to ||
    let pipe_re = Regex::new(&format!(
        r"(?i)\b{}\b\s*\|\||(?:\|\|\s*\b{}\b)",
        regex::escape(col_upper),
        regex::escape(col_upper)
    ))
    .unwrap();
    if let Some(m) = pipe_re.find(sql_upper) {
        return Some(m.start());
    }
    None
}

/// Check if column is accessed via Snowflake variant/JSON `:` operator.
fn in_variant_access(_sql_upper: &str, col_upper: &str, sql_original: &str) -> Option<usize> {
    // Snowflake variant access: identifier:path or identifier:"path"
    let orig_upper = sql_original.to_uppercase();
    let escaped = regex::escape(col_upper);
    // Pattern: something : COL  (variant extraction path)
    let pattern1 = format!("(?i):\\s*\"?{}\"?\\b", escaped);
    let re = Regex::new(&pattern1).unwrap();
    if let Some(m) = re.find(&orig_upper) {
        return Some(m.start());
    }
    // Also check: COL:something (the column itself is the variant base)
    let pattern2 = format!("(?i)\\b{}\\s*:", escaped);
    let re2 = Regex::new(&pattern2).unwrap();
    if let Some(m) = re2.find(&orig_upper) {
        return Some(m.start());
    }
    None
}

/// Check if column appears inside OBJECT_CONSTRUCT(...) or ARRAY_AGG(...).
fn in_object_construct_or_array_agg(sql_upper: &str, col_upper: &str) -> Option<usize> {
    let col_re = col_word_regex(col_upper);
    let funcs = [
        Regex::new(r"\bOBJECT_CONSTRUCT\s*\(").unwrap(),
        Regex::new(r"\bOBJECT_CONSTRUCT_KEEP_NULL\s*\(").unwrap(),
        Regex::new(r"\bARRAY_AGG\s*\(").unwrap(),
        Regex::new(r"\bARRAY_CONSTRUCT\s*\(").unwrap(),
    ];
    for func_re in &funcs {
        for m in func_re.find_iter(sql_upper) {
            let open = m.end() - 1;
            if let Some(close) = find_matching_paren(sql_upper, open) {
                let clause = &sql_upper[m.start()..=close];
                if col_re.is_match(clause) {
                    return Some(m.start());
                }
            }
        }
    }
    None
}

/// Check if column appears inside a window function's OVER (PARTITION BY ... ORDER BY ...).
fn in_window_function(sql_upper: &str, col_upper: &str) -> Option<usize> {
    let col_re = col_word_regex(col_upper);
    let over_re = Regex::new(r"\bOVER\s*\(").unwrap();
    for m in over_re.find_iter(sql_upper) {
        let open = m.end() - 1;
        if let Some(close) = find_matching_paren(sql_upper, open) {
            let clause = &sql_upper[m.start()..=close];
            // Only match if column is in PARTITION BY or ORDER BY inside OVER()
            if let Some(pb) = clause.find("PARTITION BY") {
                let remainder = &clause[pb..];
                if col_re.is_match(remainder) {
                    return Some(m.start());
                }
            }
            if let Some(ob) = clause.find("ORDER BY") {
                let remainder = &clause[ob..];
                if col_re.is_match(remainder) {
                    return Some(m.start());
                }
            }
        }
    }
    None
}

/// Check if column ONLY appears in WHERE / HAVING / JOIN ON clauses (not in SELECT list).
fn in_filter_only(sql_upper: &str, col_upper: &str) -> Option<usize> {
    let positions = find_word_positions(sql_upper, col_upper);
    if positions.is_empty() {
        return None;
    }

    // Find clause boundaries
    let where_re = Regex::new(r"\bWHERE\b").unwrap();
    let having_re = Regex::new(r"\bHAVING\b").unwrap();
    let join_on_re = Regex::new(r"\bON\b").unwrap();

    // Collect WHERE/HAVING/ON ranges (approximate)
    let mut filter_ranges: Vec<(usize, usize)> = Vec::new();

    for m in where_re.find_iter(sql_upper) {
        let end = find_clause_end(sql_upper, m.end());
        filter_ranges.push((m.start(), end));
    }
    for m in having_re.find_iter(sql_upper) {
        let end = find_clause_end(sql_upper, m.end());
        filter_ranges.push((m.start(), end));
    }
    // JOIN ... ON ... — ON clause extends until next JOIN, WHERE, GROUP BY, etc.
    for m in join_on_re.find_iter(sql_upper) {
        // Only consider ON that follows a JOIN (within ~200 chars before)
        let lookback_start = m.start().saturating_sub(200);
        let lookback = &sql_upper[lookback_start..m.start()];
        if lookback.contains("JOIN") {
            let end = find_join_on_end(sql_upper, m.end());
            filter_ranges.push((m.start(), end));
        }
    }

    if filter_ranges.is_empty() {
        return None;
    }

    // Check: does EVERY occurrence of the column fall inside a filter range?
    let all_in_filter = positions.iter().all(|&pos| {
        filter_ranges
            .iter()
            .any(|&(start, end)| pos >= start && pos < end)
    });

    if all_in_filter {
        Some(positions[0])
    } else {
        None
    }
}

/// Check if column appears inside LISTAGG(...) or STRING_AGG(...).
fn in_listagg(sql_upper: &str, col_upper: &str) -> Option<usize> {
    let col_re = col_word_regex(col_upper);
    let funcs = [
        Regex::new(r"\bLISTAGG\s*\(").unwrap(),
        Regex::new(r"\bSTRING_AGG\s*\(").unwrap(),
    ];
    for func_re in &funcs {
        for m in func_re.find_iter(sql_upper) {
            let open = m.end() - 1;
            if let Some(close) = find_matching_paren(sql_upper, open) {
                let clause = &sql_upper[m.start()..=close];
                if col_re.is_match(clause) {
                    return Some(m.start());
                }
            }
        }
    }
    None
}

/// Check if this is an INSERT INTO ... SELECT with positional column mapping.
fn is_insert_positional(sql_upper: &str) -> bool {
    let re = Regex::new(r"^\s*INSERT\s+INTO\b").unwrap();
    re.is_match(sql_upper)
}

// ===========================================================================
// Paren matching utility
// ===========================================================================

/// Find the closing paren that matches the open paren at `open_pos`.
fn find_matching_paren(sql: &str, open_pos: usize) -> Option<usize> {
    let bytes = sql.as_bytes();
    if open_pos >= bytes.len() || bytes[open_pos] != b'(' {
        return None;
    }
    let mut depth = 1u32;
    let mut i = open_pos + 1;
    while i < bytes.len() && depth > 0 {
        match bytes[i] {
            b'(' => depth += 1,
            b')' => {
                depth -= 1;
                if depth == 0 {
                    return Some(i);
                }
            }
            b'\'' => {
                // Skip string literal
                i += 1;
                while i < bytes.len() {
                    if bytes[i] == b'\'' {
                        if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
                            i += 2; // escaped quote
                            continue;
                        }
                        break;
                    }
                    i += 1;
                }
            }
            _ => {}
        }
        i += 1;
    }
    None
}

/// Find approximate end of a WHERE/HAVING clause.
fn find_clause_end(sql_upper: &str, from: usize) -> usize {
    let rest = &sql_upper[from..];
    let end_re = Regex::new(
        r"\b(?:GROUP\s+BY|ORDER\s+BY|LIMIT|QUALIFY|UNION|INTERSECT|EXCEPT|FETCH|WINDOW)\b",
    )
    .unwrap();
    end_re
        .find(rest)
        .map(|m| from + m.start())
        .unwrap_or(sql_upper.len())
}

/// Find approximate end of a JOIN ON clause.
fn find_join_on_end(sql_upper: &str, from: usize) -> usize {
    let rest = &sql_upper[from..];
    let end_re = Regex::new(
        r"\b(?:JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|LIMIT|QUALIFY|UNION|INTERSECT|EXCEPT|FETCH|WINDOW|LEFT|RIGHT|INNER|OUTER|CROSS|FULL)\b",
    )
    .unwrap();
    end_re
        .find(rest)
        .map(|m| from + m.start())
        .unwrap_or(sql_upper.len())
}

// ===========================================================================
// Master classifier
// ===========================================================================

/// Classify why a source column reference is missing by inspecting the SQL.
/// Returns the best-matching category and the byte position for snippet extraction.
fn classify_missing_source(
    sql_upper: &str,
    sql_original: &str,
    col_upper: &str,
) -> (MissingCategory, Option<usize>) {
    // Priority order: most specific constructs first

    // (a) WITHIN GROUP (ORDER BY ...)
    if let Some(pos) = in_within_group(sql_upper, col_upper) {
        return (MissingCategory::WithinGroup, Some(pos));
    }

    // (h) LISTAGG / STRING_AGG — check before generic GROUP BY
    if let Some(pos) = in_listagg(sql_upper, col_upper) {
        return (MissingCategory::ListaggOrStringAgg, Some(pos));
    }

    // (e) OBJECT_CONSTRUCT / ARRAY_AGG — check before GROUP BY
    if let Some(pos) = in_object_construct_or_array_agg(sql_upper, col_upper) {
        return (MissingCategory::ObjectConstructOrArrayAgg, Some(pos));
    }

    // (f) Window function OVER (PARTITION BY / ORDER BY)
    if let Some(pos) = in_window_function(sql_upper, col_upper) {
        return (MissingCategory::WindowFunction, Some(pos));
    }

    // (d) Variant/JSON access (: operator)
    if let Some(pos) = in_variant_access(sql_upper, col_upper, sql_original) {
        return (MissingCategory::VariantAccess, Some(pos));
    }

    // (c) CONCAT / || concatenation
    if let Some(pos) = in_concat(sql_upper, col_upper) {
        return (MissingCategory::Concat, Some(pos));
    }

    // (b) GROUP BY
    if let Some(pos) = in_group_by(sql_upper, col_upper) {
        return (MissingCategory::GroupBy, Some(pos));
    }

    // (g) Filter-only (WHERE / HAVING / JOIN ON)
    if let Some(pos) = in_filter_only(sql_upper, col_upper) {
        return (MissingCategory::FilterOnly, Some(pos));
    }

    // (i) INSERT INTO ... SELECT (positional mapping)
    if is_insert_positional(sql_upper) {
        let positions = find_word_positions(sql_upper, col_upper);
        let pos = positions.first().copied();
        return (MissingCategory::InsertPositional, pos);
    }

    // (j) Other
    let positions = find_word_positions(sql_upper, col_upper);
    (MissingCategory::Other, positions.first().copied())
}

// ===========================================================================
// Accumulators
// ===========================================================================

struct CategoryStats {
    ref_count: u64,
    query_names: HashSet<String>,
    samples: Vec<SampleCase>,
}

impl CategoryStats {
    fn new() -> Self {
        Self {
            ref_count: 0,
            query_names: HashSet::new(),
            samples: Vec::new(),
        }
    }
}

// ===========================================================================
// 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 start = std::time::Instant::now();

    let mut total_queries = 0u64;
    let mut exact_match = 0u64;
    let mut parse_fail = 0u64;
    let mut subset_queries = 0u64;
    let mut total_missing_refs = 0u64;

    // Per-category stats
    let mut stats: HashMap<MissingCategory, CategoryStats> = HashMap::new();
    for cat in &[
        MissingCategory::WithinGroup,
        MissingCategory::GroupBy,
        MissingCategory::Concat,
        MissingCategory::VariantAccess,
        MissingCategory::ObjectConstructOrArrayAgg,
        MissingCategory::WindowFunction,
        MissingCategory::FilterOnly,
        MissingCategory::ListaggOrStringAgg,
        MissingCategory::InsertPositional,
        MissingCategory::Other,
    ] {
        stats.insert(*cat, CategoryStats::new());
    }

    // -----------------------------------------------------------------------
    // Process each fixture
    // -----------------------------------------------------------------------

    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_queries += 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_to_short(&result.column_dict);

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

        // -----------------------------------------------------------------
        // Identify subset columns: output col exists in actual AND expected,
        // actual sources are a proper subset of expected sources.
        // We also include "missing output columns" (all sources missing).
        // -----------------------------------------------------------------

        // First check: ALL output columns in actual must match expected keys
        // (no extra output columns — those are a different failure mode).
        let has_extra_output = actual.keys().any(|k| !expected.contains_key(k));
        if has_extra_output {
            continue;
        }

        let mut query_has_subset = false;
        let sql_upper = case.sql.to_uppercase();

        for (col, exp_srcs) in &expected {
            let act_srcs = actual.get(col);

            let missing: Vec<&String> = match act_srcs {
                Some(act) => {
                    // Verify actual is a subset of expected (no extra sources)
                    let has_extra = act.iter().any(|a| !exp_srcs.contains(a));
                    if has_extra {
                        continue; // Mixed case — skip
                    }
                    exp_srcs.difference(act).collect()
                }
                None => {
                    // Entire output column missing — all expected sources are missing
                    exp_srcs.iter().collect()
                }
            };

            if missing.is_empty() {
                continue;
            }

            query_has_subset = true;

            // Classify each missing source reference
            for miss_ref in &missing {
                total_missing_refs += 1;
                let miss_col = col_name(miss_ref);

                let (category, match_pos) =
                    classify_missing_source(&sql_upper, &case.sql, &miss_col);

                let cat_stats = stats.get_mut(&category).unwrap();
                cat_stats.ref_count += 1;
                cat_stats.query_names.insert(case.name.clone());

                // Collect up to 2 samples per category
                if cat_stats.samples.len() < 2 {
                    let snippet = match match_pos {
                        Some(pos) => {
                            snippet_around(&case.sql, pos.min(case.sql.len().saturating_sub(1)), 80)
                        }
                        None => {
                            let prefix: String = case.sql.chars().take(150).collect();
                            format!("{prefix}...")
                        }
                    };
                    cat_stats.samples.push(SampleCase {
                        query_name: case.name.clone(),
                        output_col: col.clone(),
                        missing_ref: miss_ref.to_string(),
                        sql_snippet: snippet,
                    });
                }
            }
        }

        if query_has_subset {
            subset_queries += 1;
        }

        // Progress
        if (line_idx + 1) % 2000 == 0 {
            eprint!(
                "\r  processed {} / ~19875 lines ({:.0}%) ...",
                line_idx + 1,
                (line_idx + 1) as f64 / 19875.0 * 100.0
            );
        }
    }
    eprintln!("\r  done.                                      ");

    let elapsed = start.elapsed();

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

    println!();
    println!("{}", "=".repeat(80));
    println!("  SUBSET DEEP DIVE — Where Do Missing Source Columns Actually Appear?");
    println!("{}", "=".repeat(80));
    println!();
    println!("Total queries:          {:>7}", total_queries);
    println!("Exact match:            {:>7}", exact_match);
    println!("Parse fail:             {:>7}", parse_fail);
    println!(
        "Queries with subsets:   {:>7}  ({:.1}%)",
        subset_queries,
        if total_queries > 0 {
            subset_queries as f64 / total_queries as f64 * 100.0
        } else {
            0.0
        }
    );
    println!("Total missing refs:     {:>7}", total_missing_refs);
    println!("Elapsed:                {:.1}s", elapsed.as_secs_f64());

    // -----------------------------------------------------------------------
    // Category breakdown
    // -----------------------------------------------------------------------

    println!();
    println!("{}", "=".repeat(80));
    println!("  CATEGORY BREAKDOWN");
    println!("{}", "=".repeat(80));
    println!();
    println!(
        "  {:<48} {:>8} {:>8} {:>7}",
        "Category", "Refs", "Queries", "% Refs"
    );
    println!("  {}", "-".repeat(73));

    // Sort categories by enum order (a-j)
    let mut sorted_cats: Vec<_> = stats.iter().collect();
    sorted_cats.sort_by_key(|(cat, _)| **cat);

    for (cat, cat_stats) in &sorted_cats {
        let pct = if total_missing_refs > 0 {
            cat_stats.ref_count as f64 / total_missing_refs as f64 * 100.0
        } else {
            0.0
        };
        println!(
            "  {:<48} {:>8} {:>8} {:>6.1}%",
            cat.to_string(),
            cat_stats.ref_count,
            cat_stats.query_names.len(),
            pct,
        );
    }

    // Total line
    let total_cat_queries: usize = {
        let mut all_names = HashSet::new();
        for (_, cs) in &stats {
            for n in &cs.query_names {
                all_names.insert(n.clone());
            }
        }
        all_names.len()
    };
    println!("  {}", "-".repeat(73));
    println!(
        "  {:<48} {:>8} {:>8}",
        "TOTAL", total_missing_refs, total_cat_queries
    );

    // -----------------------------------------------------------------------
    // Sample cases per category
    // -----------------------------------------------------------------------

    println!();
    println!("{}", "=".repeat(80));
    println!("  SAMPLE CASES PER CATEGORY (up to 2 each)");
    println!("{}", "=".repeat(80));

    for (cat, cat_stats) in &sorted_cats {
        if cat_stats.samples.is_empty() {
            continue;
        }

        println!();
        println!("  --- {} ---", cat);
        println!(
            "  ({} missing refs across {} queries)",
            cat_stats.ref_count,
            cat_stats.query_names.len()
        );

        for (i, sample) in cat_stats.samples.iter().enumerate() {
            println!();
            println!("    Sample {}:", i + 1);
            println!("      Query:       {}", sample.query_name);
            println!("      Output col:  {}", sample.output_col);
            println!("      Missing ref: {}", sample.missing_ref);
            println!("      SQL context: {}", sample.sql_snippet);
        }
    }

    // -----------------------------------------------------------------------
    // Actionable summary
    // -----------------------------------------------------------------------

    println!();
    println!("{}", "=".repeat(80));
    println!("  ACTIONABLE SUMMARY");
    println!("{}", "=".repeat(80));
    println!();

    // Sort by ref count descending for prioritization
    let mut by_impact: Vec<_> = stats.iter().collect();
    by_impact.sort_by(|a, b| b.1.ref_count.cmp(&a.1.ref_count));

    println!("  Priority fixes (by # missing refs):");
    println!();
    for (rank, (cat, cat_stats)) in by_impact.iter().enumerate() {
        if cat_stats.ref_count == 0 {
            continue;
        }
        let pct = if total_missing_refs > 0 {
            cat_stats.ref_count as f64 / total_missing_refs as f64 * 100.0
        } else {
            0.0
        };
        println!(
            "    {}. {} — {} refs ({:.1}%), {} queries",
            rank + 1,
            cat,
            cat_stats.ref_count,
            pct,
            cat_stats.query_names.len(),
        );
    }

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