use crate::cli::render::{self, Icons, Theme};
use rusqlite::Connection;
use serde::Serialize;
use std::collections::{HashMap, HashSet};

use crate::Config;
use crate::cli::WhoArgs;
use crate::cli::robot::RobotMeta;
use crate::core::config::ScoringConfig;
use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result};
use crate::core::path_resolver::{PathQuery, build_path_query, normalize_repo_path};
#[cfg(test)]
use crate::core::path_resolver::{SuffixResult, escape_like, suffix_probe};
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, now_ms, parse_since, parse_since_from};

// ─── Mode Discrimination ────────────────────────────────────────────────────

/// Determines which query mode to run based on args.
/// Path variants own their strings because path normalization produces new `String`s.
/// Username variants borrow from args since no normalization is needed.
enum WhoMode<'a> {
    /// lore who <file-path>  OR  lore who --path <path>
    Expert { path: String },
    /// lore who <username>
    Workload { username: &'a str },
    /// lore who <username> --reviews
    Reviews { username: &'a str },
    /// lore who --active
    Active,
    /// lore who --overlap <path>
    Overlap { path: String },
}

fn resolve_mode<'a>(args: &'a WhoArgs) -> Result<WhoMode<'a>> {
    // Explicit --path flag always wins (handles root files like README.md,
    // LICENSE, Makefile -- anything without a / that can't be auto-detected)
    if let Some(p) = &args.path {
        return Ok(WhoMode::Expert {
            path: normalize_repo_path(p),
        });
    }
    if args.active {
        return Ok(WhoMode::Active);
    }
    if let Some(path) = &args.overlap {
        return Ok(WhoMode::Overlap {
            path: normalize_repo_path(path),
        });
    }
    if let Some(target) = &args.target {
        let clean = target.strip_prefix('@').unwrap_or(target);
        if args.reviews {
            return Ok(WhoMode::Reviews { username: clean });
        }
        // Disambiguation: if target contains '/', it's a file path.
        // GitLab usernames never contain '/'.
        // Root files (no '/') require --path.
        if clean.contains('/') {
            return Ok(WhoMode::Expert {
                path: normalize_repo_path(clean),
            });
        }
        return Ok(WhoMode::Workload { username: clean });
    }
    Err(LoreError::Other(
        "Provide a username, file path, --active, or --overlap <path>.\n\n\
         Examples:\n  \
         lore who src/features/auth/\n  \
         lore who @username\n  \
         lore who --active\n  \
         lore who --overlap src/features/\n  \
         lore who --path README.md\n  \
         lore who --path Makefile"
            .to_string(),
    ))
}

// ─── Result Types ────────────────────────────────────────────────────────────
//
// Shared result types live in core::who_types. Re-exported here for
// backwards-compatible use within the CLI.

pub use crate::core::who_types::{
    ActiveDiscussion, ActiveResult, Expert, ExpertMrDetail, ExpertResult, OverlapResult,
    OverlapUser, ReviewCategory, ReviewsResult, ScoreComponents, WhoResult, WorkloadDiscussion,
    WorkloadIssue, WorkloadMr, WorkloadResult,
};

/// Top-level run result: carries resolved inputs + the mode-specific result.
pub struct WhoRun {
    pub resolved_input: WhoResolvedInput,
    pub result: WhoResult,
}

/// Resolved query parameters -- computed once, used for robot JSON reproducibility.
pub struct WhoResolvedInput {
    pub mode: String,
    pub project_id: Option<i64>,
    pub project_path: Option<String>,
    pub since_ms: Option<i64>,
    pub since_iso: Option<String>,
    /// "default" (mode default applied), "explicit" (user provided --since), "none" (no window)
    pub since_mode: String,
    pub limit: u16,
}

/// Maximum MR references to retain per user in output (shared across modes).
const MAX_MR_REFS_PER_USER: usize = 50;

// ─── Entry Point ─────────────────────────────────────────────────────────────

/// Main entry point. Resolves mode + resolved inputs once, then dispatches.
pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun> {
    let db_path = get_db_path(config.storage.db_path.as_deref());
    let conn = create_connection(&db_path)?;

    let project_id = args
        .project
        .as_deref()
        .map(|p| resolve_project(&conn, p))
        .transpose()?;

    let project_path = project_id
        .map(|id| lookup_project_path(&conn, id))
        .transpose()?;

    let mode = resolve_mode(args)?;
    validate_mode_flags(&mode, args)?;

    // since_mode semantics:
    // - expert/reviews/active/overlap: default window applies if args.since is None -> "default"
    // - workload: no default window; args.since None => "none"
    let since_mode_for_defaulted = if args.since.is_some() {
        "explicit"
    } else {
        "default"
    };
    let since_mode_for_workload = if args.since.is_some() {
        "explicit"
    } else {
        "none"
    };

    match mode {
        WhoMode::Expert { path } => {
            // Compute as_of first so --since durations are relative to it.
            let as_of_ms = match &args.as_of {
                Some(v) => parse_since(v).ok_or_else(|| {
                    LoreError::Other(format!(
                        "Invalid --as-of value: '{v}'. Use a duration (30d, 6m) or date (2024-01-15)"
                    ))
                })?,
                None => now_ms(),
            };
            let since_ms = if args.all_history {
                0
            } else {
                resolve_since_from(args.since.as_deref(), "24m", as_of_ms)?
            };
            let limit = usize::from(args.limit);
            let result = query_expert(
                &conn,
                &path,
                project_id,
                since_ms,
                as_of_ms,
                limit,
                &config.scoring,
                args.detail,
                args.explain_score,
                args.include_bots,
            )?;
            Ok(WhoRun {
                resolved_input: WhoResolvedInput {
                    mode: "expert".to_string(),
                    project_id,
                    project_path,
                    since_ms: Some(since_ms),
                    since_iso: Some(ms_to_iso(since_ms)),
                    since_mode: since_mode_for_defaulted.to_string(),
                    limit: args.limit,
                },
                result: WhoResult::Expert(result),
            })
        }
        WhoMode::Workload { username } => {
            let since_ms = args
                .since
                .as_deref()
                .map(resolve_since_required)
                .transpose()?;
            let limit = usize::from(args.limit);
            let result = query_workload(
                &conn,
                username,
                project_id,
                since_ms,
                limit,
                args.include_closed,
            )?;
            Ok(WhoRun {
                resolved_input: WhoResolvedInput {
                    mode: "workload".to_string(),
                    project_id,
                    project_path,
                    since_ms,
                    since_iso: since_ms.map(ms_to_iso),
                    since_mode: since_mode_for_workload.to_string(),
                    limit: args.limit,
                },
                result: WhoResult::Workload(result),
            })
        }
        WhoMode::Reviews { username } => {
            let since_ms = resolve_since(args.since.as_deref(), "6m")?;
            let result = query_reviews(&conn, username, project_id, since_ms)?;
            Ok(WhoRun {
                resolved_input: WhoResolvedInput {
                    mode: "reviews".to_string(),
                    project_id,
                    project_path,
                    since_ms: Some(since_ms),
                    since_iso: Some(ms_to_iso(since_ms)),
                    since_mode: since_mode_for_defaulted.to_string(),
                    limit: args.limit,
                },
                result: WhoResult::Reviews(result),
            })
        }
        WhoMode::Active => {
            let since_ms = resolve_since(args.since.as_deref(), "7d")?;
            let limit = usize::from(args.limit);
            let result = query_active(&conn, project_id, since_ms, limit, args.include_closed)?;
            Ok(WhoRun {
                resolved_input: WhoResolvedInput {
                    mode: "active".to_string(),
                    project_id,
                    project_path,
                    since_ms: Some(since_ms),
                    since_iso: Some(ms_to_iso(since_ms)),
                    since_mode: since_mode_for_defaulted.to_string(),
                    limit: args.limit,
                },
                result: WhoResult::Active(result),
            })
        }
        WhoMode::Overlap { path } => {
            let since_ms = resolve_since(args.since.as_deref(), "30d")?;
            let limit = usize::from(args.limit);
            let result = query_overlap(&conn, &path, project_id, since_ms, limit)?;
            Ok(WhoRun {
                resolved_input: WhoResolvedInput {
                    mode: "overlap".to_string(),
                    project_id,
                    project_path,
                    since_ms: Some(since_ms),
                    since_iso: Some(ms_to_iso(since_ms)),
                    since_mode: since_mode_for_defaulted.to_string(),
                    limit: args.limit,
                },
                result: WhoResult::Overlap(result),
            })
        }
    }
}

fn validate_mode_flags(mode: &WhoMode<'_>, args: &WhoArgs) -> Result<()> {
    if args.detail && !matches!(mode, WhoMode::Expert { .. }) {
        return Err(LoreError::Other(
            "--detail is only supported in expert mode (`lore who --path <path>` or `lore who <path/with/slash>`).".to_string(),
        ));
    }
    Ok(())
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

/// Look up the project path for a resolved project ID.
fn lookup_project_path(conn: &Connection, project_id: i64) -> Result<String> {
    conn.query_row(
        "SELECT path_with_namespace FROM projects WHERE id = ?1",
        rusqlite::params![project_id],
        |row| row.get(0),
    )
    .map_err(|e| LoreError::Other(format!("Failed to look up project path: {e}")))
}

/// Parse --since with a default fallback.
fn resolve_since(input: Option<&str>, default: &str) -> Result<i64> {
    let s = input.unwrap_or(default);
    parse_since(s).ok_or_else(|| {
        LoreError::Other(format!(
            "Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
        ))
    })
}

/// Parse --since with a default fallback, relative to a reference timestamp.
/// Durations (7d, 2w, 6m) are computed from `reference_ms` instead of now.
fn resolve_since_from(input: Option<&str>, default: &str, reference_ms: i64) -> Result<i64> {
    let s = input.unwrap_or(default);
    parse_since_from(s, reference_ms).ok_or_else(|| {
        LoreError::Other(format!(
            "Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
        ))
    })
}

/// Parse --since without a default (returns error if invalid).
fn resolve_since_required(input: &str) -> Result<i64> {
    parse_since(input).ok_or_else(|| {
        LoreError::Other(format!(
            "Invalid --since value: '{input}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
        ))
    })
}

// ─── Path Query Construction ─────────────────────────────────────────────────

// ─── Scoring Helpers ─────────────────────────────────────────────────────────

/// Exponential half-life decay: `2^(-days / half_life)`.
///
/// Returns a value in `[0.0, 1.0]` representing how much of an original signal
/// is retained after `elapsed_ms` milliseconds, given a `half_life_days` period.
/// At `elapsed=0` the signal is fully retained (1.0); at `elapsed=half_life`
/// exactly half remains (0.5); the signal halves again for each additional
/// half-life period.
///
/// Returns `0.0` when `half_life_days` is zero (prevents division by zero).
/// Negative elapsed values are clamped to zero (future events retain full weight).
pub fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
    let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
    let hl = f64::from(half_life_days);
    if hl <= 0.0 {
        return 0.0;
    }
    2.0_f64.powf(-days / hl)
}

// ─── Query: Expert Mode ─────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
pub fn query_expert(
    conn: &Connection,
    path: &str,
    project_id: Option<i64>,
    since_ms: i64,
    as_of_ms: i64,
    limit: usize,
    scoring: &ScoringConfig,
    detail: bool,
    explain_score: bool,
    include_bots: bool,
) -> Result<ExpertResult> {
    let pq = build_path_query(conn, path, project_id)?;

    let sql = build_expert_sql_v2(pq.is_prefix);
    let mut stmt = conn.prepare_cached(&sql)?;

    // Params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms,
    //         ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars
    let rows = stmt.query_map(
        rusqlite::params![
            pq.value,
            since_ms,
            project_id,
            as_of_ms,
            scoring.closed_mr_multiplier,
            scoring.reviewer_min_note_chars,
        ],
        |row| {
            Ok(SignalRow {
                username: row.get(0)?,
                signal: row.get(1)?,
                mr_id: row.get(2)?,
                qty: row.get(3)?,
                ts: row.get(4)?,
                state_mult: row.get(5)?,
            })
        },
    )?;

    // Per-user accumulator keyed by username.
    let mut accum: HashMap<String, UserAccum> = HashMap::new();

    for row_result in rows {
        let r = row_result?;
        let entry = accum
            .entry(r.username.clone())
            .or_insert_with(|| UserAccum {
                contributions: Vec::new(),
                last_seen_ms: 0,
                mr_ids_author: HashSet::new(),
                mr_ids_reviewer: HashSet::new(),
                note_count: 0,
            });

        if r.ts > entry.last_seen_ms {
            entry.last_seen_ms = r.ts;
        }

        match r.signal.as_str() {
            "diffnote_author" | "file_author" => {
                entry.mr_ids_author.insert(r.mr_id);
            }
            "file_reviewer_participated" | "file_reviewer_assigned" => {
                entry.mr_ids_reviewer.insert(r.mr_id);
            }
            "note_group" => {
                entry.note_count += r.qty as u32;
                // DiffNote reviewers are also reviewer activity.
                entry.mr_ids_reviewer.insert(r.mr_id);
            }
            _ => {}
        }

        entry.contributions.push(Contribution {
            signal: r.signal,
            mr_id: r.mr_id,
            qty: r.qty,
            ts: r.ts,
            state_mult: r.state_mult,
        });
    }

    // Bot filtering: exclude configured bot usernames (case-insensitive).
    if !include_bots && !scoring.excluded_usernames.is_empty() {
        let excluded: HashSet<String> = scoring
            .excluded_usernames
            .iter()
            .map(|u| u.to_lowercase())
            .collect();
        accum.retain(|username, _| !excluded.contains(&username.to_lowercase()));
    }

    // Compute decayed scores with deterministic ordering.
    let mut scored: Vec<ScoredUser> = accum
        .into_iter()
        .map(|(username, mut ua)| {
            // Sort contributions by mr_id ASC for deterministic f64 summation.
            ua.contributions.sort_by_key(|c| c.mr_id);

            let mut comp_author = 0.0_f64;
            let mut comp_reviewer_participated = 0.0_f64;
            let mut comp_reviewer_assigned = 0.0_f64;
            let mut comp_notes = 0.0_f64;

            for c in &ua.contributions {
                let elapsed = as_of_ms - c.ts;
                match c.signal.as_str() {
                    "diffnote_author" | "file_author" => {
                        let decay = half_life_decay(elapsed, scoring.author_half_life_days);
                        comp_author += scoring.author_weight as f64 * decay * c.state_mult;
                    }
                    "file_reviewer_participated" => {
                        let decay = half_life_decay(elapsed, scoring.reviewer_half_life_days);
                        comp_reviewer_participated +=
                            scoring.reviewer_weight as f64 * decay * c.state_mult;
                    }
                    "file_reviewer_assigned" => {
                        let decay =
                            half_life_decay(elapsed, scoring.reviewer_assignment_half_life_days);
                        comp_reviewer_assigned +=
                            scoring.reviewer_assignment_weight as f64 * decay * c.state_mult;
                    }
                    "note_group" => {
                        let decay = half_life_decay(elapsed, scoring.note_half_life_days);
                        // Diminishing returns: log2(1 + count) per MR.
                        let note_value = (1.0 + c.qty as f64).log2();
                        comp_notes += scoring.note_bonus as f64 * note_value * decay * c.state_mult;
                    }
                    _ => {}
                }
            }

            let raw_score =
                comp_author + comp_reviewer_participated + comp_reviewer_assigned + comp_notes;
            ScoredUser {
                username,
                raw_score,
                components: ScoreComponents {
                    author: comp_author,
                    reviewer_participated: comp_reviewer_participated,
                    reviewer_assigned: comp_reviewer_assigned,
                    notes: comp_notes,
                },
                accum: ua,
            }
        })
        .collect();

    // Sort: raw_score DESC, last_seen DESC, username ASC (deterministic tiebreaker).
    scored.sort_by(|a, b| {
        b.raw_score
            .partial_cmp(&a.raw_score)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| b.accum.last_seen_ms.cmp(&a.accum.last_seen_ms))
            .then_with(|| a.username.cmp(&b.username))
    });

    let truncated = scored.len() > limit;
    scored.truncate(limit);

    // Build Expert structs with MR refs.
    let mut experts: Vec<Expert> = scored
        .into_iter()
        .map(|su| {
            let mut mr_refs = build_mr_refs_for_user(conn, &su.accum);
            mr_refs.sort();
            let mr_refs_total = mr_refs.len() as u32;
            let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
            if mr_refs_truncated {
                mr_refs.truncate(MAX_MR_REFS_PER_USER);
            }
            Expert {
                username: su.username,
                score: su.raw_score.round() as i64,
                score_raw: if explain_score {
                    Some(su.raw_score)
                } else {
                    None
                },
                components: if explain_score {
                    Some(su.components)
                } else {
                    None
                },
                review_mr_count: su.accum.mr_ids_reviewer.len() as u32,
                review_note_count: su.accum.note_count,
                author_mr_count: su.accum.mr_ids_author.len() as u32,
                last_seen_ms: su.accum.last_seen_ms,
                mr_refs,
                mr_refs_total,
                mr_refs_truncated,
                details: None,
            }
        })
        .collect();

    // Populate per-MR detail when --detail is requested
    if detail && !experts.is_empty() {
        let details_map = query_expert_details(conn, &pq, &experts, since_ms, project_id)?;
        for expert in &mut experts {
            expert.details = details_map.get(&expert.username).cloned();
        }
    }

    Ok(ExpertResult {
        path_query: if pq.is_prefix {
            // Use raw input (unescaped) for display — pq.value has LIKE escaping.
            path.trim_end_matches('/').to_string()
        } else {
            // For exact matches (including suffix-resolved), show the resolved path.
            pq.value.clone()
        },
        path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(),
        experts,
        truncated,
    })
}

/// Raw signal row from the v2 CTE query.
struct SignalRow {
    username: String,
    signal: String,
    mr_id: i64,
    qty: i64,
    ts: i64,
    state_mult: f64,
}

/// Per-user signal accumulator used during Rust-side scoring.
struct UserAccum {
    contributions: Vec<Contribution>,
    last_seen_ms: i64,
    mr_ids_author: HashSet<i64>,
    mr_ids_reviewer: HashSet<i64>,
    note_count: u32,
}

/// A single contribution to a user's score (one signal row).
struct Contribution {
    signal: String,
    mr_id: i64,
    qty: i64,
    ts: i64,
    state_mult: f64,
}

/// Intermediate scored user before building Expert structs.
struct ScoredUser {
    username: String,
    raw_score: f64,
    components: ScoreComponents,
    accum: UserAccum,
}

/// Build MR refs (e.g. "group/project!123") for a user from their accumulated MR IDs.
fn build_mr_refs_for_user(conn: &Connection, ua: &UserAccum) -> Vec<String> {
    let all_mr_ids: HashSet<i64> = ua
        .mr_ids_author
        .iter()
        .chain(ua.mr_ids_reviewer.iter())
        .copied()
        .chain(ua.contributions.iter().map(|c| c.mr_id))
        .collect();

    if all_mr_ids.is_empty() {
        return Vec::new();
    }

    let placeholders: Vec<String> = (1..=all_mr_ids.len()).map(|i| format!("?{i}")).collect();
    let sql = format!(
        "SELECT p.path_with_namespace || '!' || CAST(m.iid AS TEXT)
         FROM merge_requests m
         JOIN projects p ON m.project_id = p.id
         WHERE m.id IN ({})",
        placeholders.join(",")
    );

    let mut stmt = match conn.prepare(&sql) {
        Ok(s) => s,
        Err(_) => return Vec::new(),
    };

    let mut mr_ids_vec: Vec<i64> = all_mr_ids.into_iter().collect();
    mr_ids_vec.sort_unstable();
    let params: Vec<&dyn rusqlite::types::ToSql> = mr_ids_vec
        .iter()
        .map(|id| id as &dyn rusqlite::types::ToSql)
        .collect();

    stmt.query_map(&*params, |row| row.get::<_, String>(0))
        .map(|rows| rows.filter_map(|r| r.ok()).collect())
        .unwrap_or_default()
}

/// Build the CTE-based expert SQL for time-decay scoring (v2).
///
/// Returns raw signal rows `(username, signal, mr_id, qty, ts, state_mult)` that
/// Rust aggregates with per-signal decay and `log2(1+count)` for note groups.
///
/// Parameters: `?1` = path, `?2` = since_ms, `?3` = project_id (nullable),
///             `?4` = as_of_ms, `?5` = closed_mr_multiplier, `?6` = reviewer_min_note_chars
fn build_expert_sql_v2(is_prefix: bool) -> String {
    let path_op = if is_prefix {
        "LIKE ?1 ESCAPE '\\'"
    } else {
        "= ?1"
    };
    // INDEXED BY hints for each branch:
    // - new_path branch: idx_notes_diffnote_path_created (existing)
    // - old_path branch: idx_notes_old_path_author (migration 026)
    format!(
        "
WITH matched_notes_raw AS (
  -- Branch 1: match on position_new_path
  SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id
  FROM notes n INDEXED BY idx_notes_diffnote_path_created
  WHERE n.note_type = 'DiffNote'
    AND n.is_system = 0
    AND n.author_username IS NOT NULL
    AND n.created_at >= ?2
    AND n.created_at < ?4
    AND (?3 IS NULL OR n.project_id = ?3)
    AND n.position_new_path {path_op}
  UNION ALL
  -- Branch 2: match on position_old_path
  SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id
  FROM notes n INDEXED BY idx_notes_old_path_author
  WHERE n.note_type = 'DiffNote'
    AND n.is_system = 0
    AND n.author_username IS NOT NULL
    AND n.created_at >= ?2
    AND n.created_at < ?4
    AND (?3 IS NULL OR n.project_id = ?3)
    AND n.position_old_path IS NOT NULL
    AND n.position_old_path {path_op}
),
matched_notes AS (
  -- Dedup: prevent double-counting when old_path = new_path (no rename)
  SELECT DISTINCT id, discussion_id, author_username, created_at, project_id
  FROM matched_notes_raw
),
matched_file_changes_raw AS (
  -- Branch 1: match on new_path
  SELECT fc.merge_request_id, fc.project_id
  FROM mr_file_changes fc INDEXED BY idx_mfc_new_path_project_mr
  WHERE (?3 IS NULL OR fc.project_id = ?3)
    AND fc.new_path {path_op}
  UNION ALL
  -- Branch 2: match on old_path
  SELECT fc.merge_request_id, fc.project_id
  FROM mr_file_changes fc INDEXED BY idx_mfc_old_path_project_mr
  WHERE (?3 IS NULL OR fc.project_id = ?3)
    AND fc.old_path IS NOT NULL
    AND fc.old_path {path_op}
),
matched_file_changes AS (
  -- Dedup: prevent double-counting when old_path = new_path (no rename)
  SELECT DISTINCT merge_request_id, project_id
  FROM matched_file_changes_raw
),
mr_activity AS (
  -- Centralized state-aware timestamps and state multiplier.
  -- Scoped to MRs matched by file changes to avoid materializing the full MR table.
  SELECT DISTINCT
    m.id AS mr_id,
    m.author_username,
    m.state,
    CASE
      WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at)
      WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at)
      ELSE COALESCE(m.updated_at, m.created_at)
    END AS activity_ts,
    CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
  FROM merge_requests m
  JOIN matched_file_changes mfc ON mfc.merge_request_id = m.id
  WHERE m.state IN ('opened','merged','closed')
),
reviewer_participation AS (
  -- Precompute which (mr_id, username) pairs have substantive DiffNote participation.
  SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username
  FROM matched_notes mn
  JOIN discussions d ON mn.discussion_id = d.id
  JOIN notes n_body ON mn.id = n_body.id
  WHERE d.merge_request_id IS NOT NULL
    AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6
),
raw AS (
  -- Signal 1: DiffNote reviewer (individual notes for note_cnt)
  SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal,
         m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at,
         CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
  FROM matched_notes mn
  JOIN discussions d ON mn.discussion_id = d.id
  JOIN merge_requests m ON d.merge_request_id = m.id
  WHERE (m.author_username IS NULL OR mn.author_username != m.author_username)
    AND m.state IN ('opened','merged','closed')

  UNION ALL

  -- Signal 2: DiffNote MR author
  SELECT m.author_username AS username, 'diffnote_author' AS signal,
         m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at,
         CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN matched_notes mn ON mn.discussion_id = d.id
  WHERE m.author_username IS NOT NULL
    AND m.state IN ('opened','merged','closed')
  GROUP BY m.author_username, m.id

  UNION ALL

  -- Signal 3: MR author via file changes (uses mr_activity CTE)
  SELECT a.author_username AS username, 'file_author' AS signal,
         a.mr_id, NULL AS note_id,
         a.activity_ts AS seen_at, a.state_mult
  FROM mr_activity a
  WHERE a.author_username IS NOT NULL
    AND a.activity_ts >= ?2
    AND a.activity_ts < ?4

  UNION ALL

  -- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path)
  SELECT r.username AS username, 'file_reviewer_participated' AS signal,
         a.mr_id, NULL AS note_id,
         a.activity_ts AS seen_at, a.state_mult
  FROM mr_activity a
  JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
  JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
  WHERE r.username IS NOT NULL
    AND (a.author_username IS NULL OR r.username != a.author_username)
    AND a.activity_ts >= ?2
    AND a.activity_ts < ?4

  UNION ALL

  -- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path)
  SELECT r.username AS username, 'file_reviewer_assigned' AS signal,
         a.mr_id, NULL AS note_id,
         a.activity_ts AS seen_at, a.state_mult
  FROM mr_activity a
  JOIN mr_reviewers r ON r.merge_request_id = a.mr_id
  LEFT JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username
  WHERE rp.username IS NULL
    AND r.username IS NOT NULL
    AND (a.author_username IS NULL OR r.username != a.author_username)
    AND a.activity_ts >= ?2
    AND a.activity_ts < ?4
),
aggregated AS (
  -- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts)
  SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult
  FROM raw WHERE signal != 'diffnote_reviewer'
  GROUP BY username, signal, mr_id
  UNION ALL
  -- Note signals: 1 row per (username, mr_id) with note_count and max_ts
  SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts,
         MAX(state_mult) AS state_mult
  FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL
  GROUP BY username, mr_id
)
SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE username IS NOT NULL
        "
    )
}

/// Query per-MR detail for a set of experts. Returns a map of username -> Vec<ExpertMrDetail>.
fn query_expert_details(
    conn: &Connection,
    pq: &PathQuery,
    experts: &[Expert],
    since_ms: i64,
    project_id: Option<i64>,
) -> Result<HashMap<String, Vec<ExpertMrDetail>>> {
    let path_op = if pq.is_prefix {
        "LIKE ?1 ESCAPE '\\'"
    } else {
        "= ?1"
    };

    // Build IN clause for usernames
    let placeholders: Vec<String> = experts
        .iter()
        .enumerate()
        .map(|(i, _)| format!("?{}", i + 4))
        .collect();
    let in_clause = placeholders.join(",");

    let sql = format!(
        "
        WITH signals AS (
            -- 1. DiffNote reviewer (matches both new_path and old_path for renamed files)
            SELECT
                n.author_username AS username,
                'reviewer' AS role,
                m.id AS mr_id,
                (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref,
                m.title AS title,
                COUNT(*) AS note_count,
                MAX(n.created_at) AS last_activity
            FROM notes n
            JOIN discussions d ON n.discussion_id = d.id
            JOIN merge_requests m ON d.merge_request_id = m.id
            JOIN projects p ON m.project_id = p.id
            WHERE n.note_type = 'DiffNote'
              AND n.is_system = 0
              AND n.author_username IS NOT NULL
              AND (m.author_username IS NULL OR n.author_username != m.author_username)
              AND m.state IN ('opened','merged','closed')
              AND (n.position_new_path {path_op}
                   OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
              AND n.created_at >= ?2
              AND (?3 IS NULL OR n.project_id = ?3)
              AND n.author_username IN ({in_clause})
            GROUP BY n.author_username, m.id

            UNION ALL

            -- 2. DiffNote MR author (matches both new_path and old_path for renamed files)
            SELECT
                m.author_username AS username,
                'author' AS role,
                m.id AS mr_id,
                (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref,
                m.title AS title,
                0 AS note_count,
                MAX(n.created_at) AS last_activity
            FROM merge_requests m
            JOIN discussions d ON d.merge_request_id = m.id
            JOIN notes n ON n.discussion_id = d.id
            JOIN projects p ON m.project_id = p.id
            WHERE n.note_type = 'DiffNote'
              AND n.is_system = 0
              AND m.author_username IS NOT NULL
              AND m.state IN ('opened','merged','closed')
              AND (n.position_new_path {path_op}
                   OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
              AND n.created_at >= ?2
              AND (?3 IS NULL OR n.project_id = ?3)
              AND m.author_username IN ({in_clause})
            GROUP BY m.author_username, m.id

            UNION ALL

            -- 3. MR author via file changes (matches both new_path and old_path)
            SELECT
                m.author_username AS username,
                'author' AS role,
                m.id AS mr_id,
                (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref,
                m.title AS title,
                0 AS note_count,
                m.updated_at AS last_activity
            FROM mr_file_changes fc
            JOIN merge_requests m ON fc.merge_request_id = m.id
            JOIN projects p ON m.project_id = p.id
            WHERE m.author_username IS NOT NULL
              AND m.state IN ('opened','merged','closed')
              AND (fc.new_path {path_op}
                   OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
              AND m.updated_at >= ?2
              AND (?3 IS NULL OR fc.project_id = ?3)
              AND m.author_username IN ({in_clause})

            UNION ALL

            -- 4. MR reviewer via file changes + mr_reviewers (matches both new_path and old_path)
            SELECT
                r.username AS username,
                'reviewer' AS role,
                m.id AS mr_id,
                (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref,
                m.title AS title,
                0 AS note_count,
                m.updated_at AS last_activity
            FROM mr_file_changes fc
            JOIN merge_requests m ON fc.merge_request_id = m.id
            JOIN projects p ON m.project_id = p.id
            JOIN mr_reviewers r ON r.merge_request_id = m.id
            WHERE r.username IS NOT NULL
              AND (m.author_username IS NULL OR r.username != m.author_username)
              AND m.state IN ('opened','merged','closed')
              AND (fc.new_path {path_op}
                   OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
              AND m.updated_at >= ?2
              AND (?3 IS NULL OR fc.project_id = ?3)
              AND r.username IN ({in_clause})
        )
        SELECT
            username,
            mr_ref,
            title,
            GROUP_CONCAT(DISTINCT role) AS roles,
            SUM(note_count) AS total_notes,
            MAX(last_activity) AS last_activity
        FROM signals
        GROUP BY username, mr_ref
        ORDER BY username ASC, last_activity DESC
        "
    );

    // prepare() not prepare_cached(): the IN clause varies by expert count,
    // so the SQL shape changes per invocation and caching wastes memory.
    let mut stmt = conn.prepare(&sql)?;

    // Build params: ?1=path, ?2=since_ms, ?3=project_id, ?4..=usernames
    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
    params.push(Box::new(pq.value.clone()));
    params.push(Box::new(since_ms));
    params.push(Box::new(project_id));
    for expert in experts {
        params.push(Box::new(expert.username.clone()));
    }
    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let rows: Vec<(String, String, String, String, u32, i64)> = stmt
        .query_map(param_refs.as_slice(), |row| {
            Ok((
                row.get(0)?,
                row.get(1)?,
                row.get(2)?,
                row.get::<_, String>(3)?,
                row.get(4)?,
                row.get(5)?,
            ))
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    let mut map: HashMap<String, Vec<ExpertMrDetail>> = HashMap::new();
    for (username, mr_ref, title, roles_csv, note_count, last_activity) in rows {
        let has_author = roles_csv.contains("author");
        let has_reviewer = roles_csv.contains("reviewer");
        let role = match (has_author, has_reviewer) {
            (true, true) => "A+R",
            (true, false) => "A",
            (false, true) => "R",
            _ => "?",
        }
        .to_string();
        map.entry(username).or_default().push(ExpertMrDetail {
            mr_ref,
            title,
            role,
            note_count,
            last_activity_ms: last_activity,
        });
    }

    Ok(map)
}

// ─── Query: Workload Mode ───────────────────────────────────────────────────

pub fn query_workload(
    conn: &Connection,
    username: &str,
    project_id: Option<i64>,
    since_ms: Option<i64>,
    limit: usize,
    include_closed: bool,
) -> Result<WorkloadResult> {
    let limit_plus_one = (limit + 1) as i64;

    // Query 1: Open issues assigned to user
    let issues_sql = "SELECT i.iid,
                (p.path_with_namespace || '#' || i.iid) AS ref,
                i.title, p.path_with_namespace, i.updated_at
         FROM issues i
         JOIN issue_assignees ia ON ia.issue_id = i.id
         JOIN projects p ON i.project_id = p.id
         WHERE ia.username = ?1
           AND i.state = 'opened'
           AND (?2 IS NULL OR i.project_id = ?2)
           AND (?3 IS NULL OR i.updated_at >= ?3)
         ORDER BY i.updated_at DESC
         LIMIT ?4";

    let mut stmt = conn.prepare_cached(issues_sql)?;
    let assigned_issues: Vec<WorkloadIssue> = stmt
        .query_map(
            rusqlite::params![username, project_id, since_ms, limit_plus_one],
            |row| {
                Ok(WorkloadIssue {
                    iid: row.get(0)?,
                    ref_: row.get(1)?,
                    title: row.get(2)?,
                    project_path: row.get(3)?,
                    updated_at: row.get(4)?,
                })
            },
        )?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    // Query 2: Open MRs authored
    let authored_sql = "SELECT m.iid,
                (p.path_with_namespace || '!' || m.iid) AS ref,
                m.title, m.draft, p.path_with_namespace, m.updated_at
         FROM merge_requests m
         JOIN projects p ON m.project_id = p.id
         WHERE m.author_username = ?1
           AND m.state = 'opened'
           AND (?2 IS NULL OR m.project_id = ?2)
           AND (?3 IS NULL OR m.updated_at >= ?3)
         ORDER BY m.updated_at DESC
         LIMIT ?4";
    let mut stmt = conn.prepare_cached(authored_sql)?;
    let authored_mrs: Vec<WorkloadMr> = stmt
        .query_map(
            rusqlite::params![username, project_id, since_ms, limit_plus_one],
            |row| {
                Ok(WorkloadMr {
                    iid: row.get(0)?,
                    ref_: row.get(1)?,
                    title: row.get(2)?,
                    draft: row.get::<_, i32>(3)? != 0,
                    project_path: row.get(4)?,
                    author_username: None,
                    updated_at: row.get(5)?,
                })
            },
        )?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    // Query 3: Open MRs where user is reviewer
    let reviewing_sql = "SELECT m.iid,
                (p.path_with_namespace || '!' || m.iid) AS ref,
                m.title, m.draft, p.path_with_namespace,
                m.author_username, m.updated_at
         FROM merge_requests m
         JOIN mr_reviewers r ON r.merge_request_id = m.id
         JOIN projects p ON m.project_id = p.id
         WHERE r.username = ?1
           AND m.state = 'opened'
           AND (?2 IS NULL OR m.project_id = ?2)
           AND (?3 IS NULL OR m.updated_at >= ?3)
         ORDER BY m.updated_at DESC
         LIMIT ?4";
    let mut stmt = conn.prepare_cached(reviewing_sql)?;
    let reviewing_mrs: Vec<WorkloadMr> = stmt
        .query_map(
            rusqlite::params![username, project_id, since_ms, limit_plus_one],
            |row| {
                Ok(WorkloadMr {
                    iid: row.get(0)?,
                    ref_: row.get(1)?,
                    title: row.get(2)?,
                    draft: row.get::<_, i32>(3)? != 0,
                    project_path: row.get(4)?,
                    author_username: row.get(5)?,
                    updated_at: row.get(6)?,
                })
            },
        )?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    // Query 4: Unresolved discussions where user participated
    let state_filter = if include_closed {
        ""
    } else {
        " AND (i.id IS NULL OR i.state = 'opened')
           AND (m.id IS NULL OR m.state = 'opened')"
    };
    let disc_sql = format!(
        "SELECT d.noteable_type,
                COALESCE(i.iid, m.iid) AS entity_iid,
                (p.path_with_namespace ||
                 CASE WHEN d.noteable_type = 'MergeRequest' THEN '!' ELSE '#' END ||
                 COALESCE(i.iid, m.iid)) AS ref,
                COALESCE(i.title, m.title) AS entity_title,
                p.path_with_namespace,
                d.last_note_at
         FROM discussions d
         JOIN projects p ON d.project_id = p.id
         LEFT JOIN issues i ON d.issue_id = i.id
         LEFT JOIN merge_requests m ON d.merge_request_id = m.id
         WHERE d.resolvable = 1 AND d.resolved = 0
           AND EXISTS (
             SELECT 1 FROM notes n
             WHERE n.discussion_id = d.id
             AND n.author_username = ?1
             AND n.is_system = 0
           )
           AND (?2 IS NULL OR d.project_id = ?2)
           AND (?3 IS NULL OR d.last_note_at >= ?3)
           {state_filter}
         ORDER BY d.last_note_at DESC
         LIMIT ?4"
    );

    let mut stmt = conn.prepare_cached(&disc_sql)?;
    let unresolved_discussions: Vec<WorkloadDiscussion> = stmt
        .query_map(
            rusqlite::params![username, project_id, since_ms, limit_plus_one],
            |row| {
                let noteable_type: String = row.get(0)?;
                let entity_type = if noteable_type == "MergeRequest" {
                    "MR"
                } else {
                    "Issue"
                };
                Ok(WorkloadDiscussion {
                    entity_type: entity_type.to_string(),
                    entity_iid: row.get(1)?,
                    ref_: row.get(2)?,
                    entity_title: row.get(3)?,
                    project_path: row.get(4)?,
                    last_note_at: row.get(5)?,
                })
            },
        )?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    // Truncation detection
    let assigned_issues_truncated = assigned_issues.len() > limit;
    let authored_mrs_truncated = authored_mrs.len() > limit;
    let reviewing_mrs_truncated = reviewing_mrs.len() > limit;
    let unresolved_discussions_truncated = unresolved_discussions.len() > limit;

    let assigned_issues: Vec<WorkloadIssue> = assigned_issues.into_iter().take(limit).collect();
    let authored_mrs: Vec<WorkloadMr> = authored_mrs.into_iter().take(limit).collect();
    let reviewing_mrs: Vec<WorkloadMr> = reviewing_mrs.into_iter().take(limit).collect();
    let unresolved_discussions: Vec<WorkloadDiscussion> =
        unresolved_discussions.into_iter().take(limit).collect();

    Ok(WorkloadResult {
        username: username.to_string(),
        assigned_issues,
        authored_mrs,
        reviewing_mrs,
        unresolved_discussions,
        assigned_issues_truncated,
        authored_mrs_truncated,
        reviewing_mrs_truncated,
        unresolved_discussions_truncated,
    })
}

// ─── Query: Reviews Mode ────────────────────────────────────────────────────

pub fn query_reviews(
    conn: &Connection,
    username: &str,
    project_id: Option<i64>,
    since_ms: i64,
) -> Result<ReviewsResult> {
    // Force the partial index on DiffNote queries (same rationale as expert mode).
    // COUNT + COUNT(DISTINCT) + category extraction all benefit from 26K DiffNote
    // scan vs 282K notes full scan: measured 25x speedup.
    let total_sql = "SELECT COUNT(*) FROM notes n
         INDEXED BY idx_notes_diffnote_path_created
         JOIN discussions d ON n.discussion_id = d.id
         JOIN merge_requests m ON d.merge_request_id = m.id
         WHERE n.author_username = ?1
           AND n.note_type = 'DiffNote'
           AND n.is_system = 0
           AND (m.author_username IS NULL OR m.author_username != ?1)
           AND n.created_at >= ?2
           AND (?3 IS NULL OR n.project_id = ?3)";

    let total_diffnotes: u32 = conn.query_row(
        total_sql,
        rusqlite::params![username, since_ms, project_id],
        |row| row.get(0),
    )?;

    // Count distinct MRs reviewed
    let mrs_sql = "SELECT COUNT(DISTINCT m.id) FROM notes n
         INDEXED BY idx_notes_diffnote_path_created
         JOIN discussions d ON n.discussion_id = d.id
         JOIN merge_requests m ON d.merge_request_id = m.id
         WHERE n.author_username = ?1
           AND n.note_type = 'DiffNote'
           AND n.is_system = 0
           AND (m.author_username IS NULL OR m.author_username != ?1)
           AND n.created_at >= ?2
           AND (?3 IS NULL OR n.project_id = ?3)";

    let mrs_reviewed: u32 = conn.query_row(
        mrs_sql,
        rusqlite::params![username, since_ms, project_id],
        |row| row.get(0),
    )?;

    // Extract prefixed categories: body starts with **prefix**
    let cat_sql = "SELECT
            SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
            COUNT(*) AS cnt
         FROM notes n INDEXED BY idx_notes_diffnote_path_created
         JOIN discussions d ON n.discussion_id = d.id
         JOIN merge_requests m ON d.merge_request_id = m.id
         WHERE n.author_username = ?1
           AND n.note_type = 'DiffNote'
           AND n.is_system = 0
           AND (m.author_username IS NULL OR m.author_username != ?1)
           AND ltrim(n.body) LIKE '**%**%'
           AND n.created_at >= ?2
           AND (?3 IS NULL OR n.project_id = ?3)
         GROUP BY raw_prefix
         ORDER BY cnt DESC";

    let mut stmt = conn.prepare_cached(cat_sql)?;
    let raw_categories: Vec<(String, u32)> = stmt
        .query_map(rusqlite::params![username, since_ms, project_id], |row| {
            Ok((row.get::<_, String>(0)?, row.get(1)?))
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    // Normalize categories: lowercase, strip trailing colon/space,
    // merge nit/nitpick variants, merge (non-blocking) variants
    let mut merged: HashMap<String, u32> = HashMap::new();
    for (raw, count) in &raw_categories {
        let normalized = normalize_review_prefix(raw);
        if !normalized.is_empty() {
            *merged.entry(normalized).or_insert(0) += count;
        }
    }

    let categorized_count: u32 = merged.values().sum();

    let mut categories: Vec<ReviewCategory> = merged
        .into_iter()
        .map(|(name, count)| {
            let percentage = if categorized_count > 0 {
                f64::from(count) / f64::from(categorized_count) * 100.0
            } else {
                0.0
            };
            ReviewCategory {
                name,
                count,
                percentage,
            }
        })
        .collect();

    categories.sort_by_key(|cat| std::cmp::Reverse(cat.count));

    Ok(ReviewsResult {
        username: username.to_string(),
        total_diffnotes,
        categorized_count,
        mrs_reviewed,
        categories,
    })
}

/// Normalize a raw review prefix like "Suggestion (non-blocking):" into "suggestion".
fn normalize_review_prefix(raw: &str) -> String {
    let s = raw.trim().trim_end_matches(':').trim().to_lowercase();

    // Strip "(non-blocking)" and similar parentheticals
    let s = if let Some(idx) = s.find('(') {
        s[..idx].trim().to_string()
    } else {
        s
    };

    // Merge nit/nitpick variants
    match s.as_str() {
        "nitpick" | "nit" => "nit".to_string(),
        other => other.to_string(),
    }
}

// ─── Query: Active Mode ─────────────────────────────────────────────────────

pub fn query_active(
    conn: &Connection,
    project_id: Option<i64>,
    since_ms: i64,
    limit: usize,
    include_closed: bool,
) -> Result<ActiveResult> {
    let limit_plus_one = (limit + 1) as i64;

    // State filter for open-entities-only (default behavior)
    let state_joins = if include_closed {
        ""
    } else {
        " LEFT JOIN issues i ON d.issue_id = i.id
         LEFT JOIN merge_requests m ON d.merge_request_id = m.id"
    };
    let state_filter = if include_closed {
        ""
    } else {
        " AND (i.id IS NULL OR i.state = 'opened')
           AND (m.id IS NULL OR m.state = 'opened')"
    };

    // Total unresolved count -- conditionally built
    let total_sql_global = format!(
        "SELECT COUNT(*) FROM discussions d
         {state_joins}
         WHERE d.resolvable = 1 AND d.resolved = 0
           AND d.last_note_at >= ?1
           {state_filter}"
    );
    let total_sql_scoped = format!(
        "SELECT COUNT(*) FROM discussions d
         {state_joins}
         WHERE d.resolvable = 1 AND d.resolved = 0
           AND d.last_note_at >= ?1
           AND d.project_id = ?2
           {state_filter}"
    );

    let total_unresolved_in_window: u32 = match project_id {
        None => conn.query_row(&total_sql_global, rusqlite::params![since_ms], |row| {
            row.get(0)
        })?,
        Some(pid) => {
            conn.query_row(&total_sql_scoped, rusqlite::params![since_ms, pid], |row| {
                row.get(0)
            })?
        }
    };

    // Active discussions with context -- conditionally built SQL
    let sql_global = format!(
        "
        WITH picked AS (
            SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
                   d.project_id, d.last_note_at
            FROM discussions d
            {state_joins}
            WHERE d.resolvable = 1 AND d.resolved = 0
              AND d.last_note_at >= ?1
              {state_filter}
            ORDER BY d.last_note_at DESC
            LIMIT ?2
        ),
        note_counts AS (
            SELECT
                n.discussion_id,
                COUNT(*) AS note_count
            FROM notes n
            JOIN picked p ON p.id = n.discussion_id
            WHERE n.is_system = 0
            GROUP BY n.discussion_id
        ),
        participants AS (
            SELECT
                x.discussion_id,
                GROUP_CONCAT(x.author_username, X'1F') AS participants
            FROM (
                SELECT DISTINCT n.discussion_id, n.author_username
                FROM notes n
                JOIN picked p ON p.id = n.discussion_id
                WHERE n.is_system = 0 AND n.author_username IS NOT NULL
            ) x
            GROUP BY x.discussion_id
        )
        SELECT
            p.id AS discussion_id,
            p.noteable_type,
            COALESCE(i.iid, m.iid) AS entity_iid,
            COALESCE(i.title, m.title) AS entity_title,
            proj.path_with_namespace,
            p.last_note_at,
            COALESCE(nc.note_count, 0) AS note_count,
            COALESCE(pa.participants, '') AS participants
        FROM picked p
        JOIN projects proj ON p.project_id = proj.id
        LEFT JOIN issues i ON p.issue_id = i.id
        LEFT JOIN merge_requests m ON p.merge_request_id = m.id
        LEFT JOIN note_counts nc ON nc.discussion_id = p.id
        LEFT JOIN participants pa ON pa.discussion_id = p.id
        ORDER BY p.last_note_at DESC
    "
    );

    let sql_scoped = format!(
        "
        WITH picked AS (
            SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
                   d.project_id, d.last_note_at
            FROM discussions d
            {state_joins}
            WHERE d.resolvable = 1 AND d.resolved = 0
              AND d.last_note_at >= ?1
              AND d.project_id = ?2
              {state_filter}
            ORDER BY d.last_note_at DESC
            LIMIT ?3
        ),
        note_counts AS (
            SELECT
                n.discussion_id,
                COUNT(*) AS note_count
            FROM notes n
            JOIN picked p ON p.id = n.discussion_id
            WHERE n.is_system = 0
            GROUP BY n.discussion_id
        ),
        participants AS (
            SELECT
                x.discussion_id,
                GROUP_CONCAT(x.author_username, X'1F') AS participants
            FROM (
                SELECT DISTINCT n.discussion_id, n.author_username
                FROM notes n
                JOIN picked p ON p.id = n.discussion_id
                WHERE n.is_system = 0 AND n.author_username IS NOT NULL
            ) x
            GROUP BY x.discussion_id
        )
        SELECT
            p.id AS discussion_id,
            p.noteable_type,
            COALESCE(i.iid, m.iid) AS entity_iid,
            COALESCE(i.title, m.title) AS entity_title,
            proj.path_with_namespace,
            p.last_note_at,
            COALESCE(nc.note_count, 0) AS note_count,
            COALESCE(pa.participants, '') AS participants
        FROM picked p
        JOIN projects proj ON p.project_id = proj.id
        LEFT JOIN issues i ON p.issue_id = i.id
        LEFT JOIN merge_requests m ON p.merge_request_id = m.id
        LEFT JOIN note_counts nc ON nc.discussion_id = p.id
        LEFT JOIN participants pa ON pa.discussion_id = p.id
        ORDER BY p.last_note_at DESC
    "
    );

    // Row-mapping closure shared between both variants
    let map_row = |row: &rusqlite::Row| -> rusqlite::Result<ActiveDiscussion> {
        let noteable_type: String = row.get(1)?;
        let entity_type = if noteable_type == "MergeRequest" {
            "MR"
        } else {
            "Issue"
        };
        let participants_csv: Option<String> = row.get(7)?;
        // Sort participants for deterministic output -- GROUP_CONCAT order is undefined
        let mut participants: Vec<String> = participants_csv
            .as_deref()
            .filter(|s| !s.is_empty())
            .map(|csv| csv.split('\x1F').map(String::from).collect())
            .unwrap_or_default();
        participants.sort();

        const MAX_PARTICIPANTS: usize = 50;
        let participants_total = participants.len() as u32;
        let participants_truncated = participants.len() > MAX_PARTICIPANTS;
        if participants_truncated {
            participants.truncate(MAX_PARTICIPANTS);
        }

        Ok(ActiveDiscussion {
            discussion_id: row.get(0)?,
            entity_type: entity_type.to_string(),
            entity_iid: row.get(2)?,
            entity_title: row.get(3)?,
            project_path: row.get(4)?,
            last_note_at: row.get(5)?,
            note_count: row.get(6)?,
            participants,
            participants_total,
            participants_truncated,
        })
    };

    // Select variant first, then prepare exactly one statement
    let discussions: Vec<ActiveDiscussion> = match project_id {
        None => {
            let mut stmt = conn.prepare_cached(&sql_global)?;
            stmt.query_map(rusqlite::params![since_ms, limit_plus_one], &map_row)?
                .collect::<std::result::Result<Vec<_>, _>>()?
        }
        Some(pid) => {
            let mut stmt = conn.prepare_cached(&sql_scoped)?;
            stmt.query_map(rusqlite::params![since_ms, pid, limit_plus_one], &map_row)?
                .collect::<std::result::Result<Vec<_>, _>>()?
        }
    };

    let truncated = discussions.len() > limit;
    let discussions: Vec<ActiveDiscussion> = discussions.into_iter().take(limit).collect();

    Ok(ActiveResult {
        discussions,
        total_unresolved_in_window,
        truncated,
    })
}

// ─── Query: Overlap Mode ────────────────────────────────────────────────────

pub fn query_overlap(
    conn: &Connection,
    path: &str,
    project_id: Option<i64>,
    since_ms: i64,
    limit: usize,
) -> Result<OverlapResult> {
    let pq = build_path_query(conn, path, project_id)?;

    // Build SQL with 4 signal sources, matching the expert query expansion.
    // Each row produces (username, role, mr_id, mr_ref, seen_at) for Rust-side accumulation.
    let path_op = if pq.is_prefix {
        "LIKE ?1 ESCAPE '\\'"
    } else {
        "= ?1"
    };
    // Match both new_path and old_path to capture activity on renamed files.
    // INDEXED BY removed to allow OR across path columns; overlap runs once
    // per command so the minor plan difference is acceptable.
    let sql = format!(
        "SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
            -- 1. DiffNote reviewer (matches both new_path and old_path)
            SELECT
                n.author_username AS username,
                'reviewer' AS role,
                COUNT(DISTINCT m.id) AS touch_count,
                MAX(n.created_at) AS last_seen_at,
                GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
            FROM notes n
            JOIN discussions d ON n.discussion_id = d.id
            JOIN merge_requests m ON d.merge_request_id = m.id
            JOIN projects p ON m.project_id = p.id
            WHERE n.note_type = 'DiffNote'
              AND (n.position_new_path {path_op}
                   OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
              AND n.is_system = 0
              AND n.author_username IS NOT NULL
              AND (m.author_username IS NULL OR n.author_username != m.author_username)
              AND m.state IN ('opened','merged','closed')
              AND n.created_at >= ?2
              AND (?3 IS NULL OR n.project_id = ?3)
            GROUP BY n.author_username

            UNION ALL

            -- 2. DiffNote MR author (matches both new_path and old_path)
            SELECT
                m.author_username AS username,
                'author' AS role,
                COUNT(DISTINCT m.id) AS touch_count,
                MAX(n.created_at) AS last_seen_at,
                GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
            FROM notes n
            JOIN discussions d ON n.discussion_id = d.id
            JOIN merge_requests m ON d.merge_request_id = m.id
            JOIN projects p ON m.project_id = p.id
            WHERE n.note_type = 'DiffNote'
              AND (n.position_new_path {path_op}
                   OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
              AND n.is_system = 0
              AND m.state IN ('opened','merged','closed')
              AND m.author_username IS NOT NULL
              AND n.created_at >= ?2
              AND (?3 IS NULL OR n.project_id = ?3)
            GROUP BY m.author_username

            UNION ALL

            -- 3. MR author via file changes (matches both new_path and old_path)
            SELECT
                m.author_username AS username,
                'author' AS role,
                COUNT(DISTINCT m.id) AS touch_count,
                MAX(m.updated_at) AS last_seen_at,
                GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
            FROM mr_file_changes fc
            JOIN merge_requests m ON fc.merge_request_id = m.id
            JOIN projects p ON m.project_id = p.id
            WHERE m.author_username IS NOT NULL
              AND m.state IN ('opened','merged','closed')
              AND (fc.new_path {path_op}
                   OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
              AND m.updated_at >= ?2
              AND (?3 IS NULL OR fc.project_id = ?3)
            GROUP BY m.author_username

            UNION ALL

            -- 4. MR reviewer via file changes + mr_reviewers (matches both new_path and old_path)
            SELECT
                r.username AS username,
                'reviewer' AS role,
                COUNT(DISTINCT m.id) AS touch_count,
                MAX(m.updated_at) AS last_seen_at,
                GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
            FROM mr_file_changes fc
            JOIN merge_requests m ON fc.merge_request_id = m.id
            JOIN projects p ON m.project_id = p.id
            JOIN mr_reviewers r ON r.merge_request_id = m.id
            WHERE r.username IS NOT NULL
              AND (m.author_username IS NULL OR r.username != m.author_username)
              AND m.state IN ('opened','merged','closed')
              AND (fc.new_path {path_op}
                   OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
              AND m.updated_at >= ?2
              AND (?3 IS NULL OR fc.project_id = ?3)
            GROUP BY r.username
        )"
    );

    let mut stmt = conn.prepare_cached(&sql)?;
    let rows: Vec<(String, String, u32, i64, Option<String>)> = stmt
        .query_map(rusqlite::params![pq.value, since_ms, project_id], |row| {
            Ok((
                row.get(0)?,
                row.get(1)?,
                row.get(2)?,
                row.get(3)?,
                row.get(4)?,
            ))
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    // Internal accumulator uses HashSet for MR refs from the start
    struct OverlapAcc {
        username: String,
        author_touch_count: u32,
        review_touch_count: u32,
        touch_count: u32,
        last_seen_at: i64,
        mr_refs: HashSet<String>,
    }

    let mut user_map: HashMap<String, OverlapAcc> = HashMap::new();
    for (username, role, count, last_seen, mr_refs_csv) in &rows {
        let mr_refs: Vec<String> = mr_refs_csv
            .as_deref()
            .map(|csv| csv.split(',').map(|s| s.trim().to_string()).collect())
            .unwrap_or_default();

        let entry = user_map
            .entry(username.clone())
            .or_insert_with(|| OverlapAcc {
                username: username.clone(),
                author_touch_count: 0,
                review_touch_count: 0,
                touch_count: 0,
                last_seen_at: 0,
                mr_refs: HashSet::new(),
            });
        entry.touch_count += count;
        if role == "author" {
            entry.author_touch_count += count;
        } else {
            entry.review_touch_count += count;
        }
        if *last_seen > entry.last_seen_at {
            entry.last_seen_at = *last_seen;
        }
        for r in mr_refs {
            entry.mr_refs.insert(r);
        }
    }

    // Convert accumulators to output structs
    let mut users: Vec<OverlapUser> = user_map
        .into_values()
        .map(|a| {
            let mut mr_refs: Vec<String> = a.mr_refs.into_iter().collect();
            mr_refs.sort();
            let mr_refs_total = mr_refs.len() as u32;
            let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
            if mr_refs_truncated {
                mr_refs.truncate(MAX_MR_REFS_PER_USER);
            }
            OverlapUser {
                username: a.username,
                author_touch_count: a.author_touch_count,
                review_touch_count: a.review_touch_count,
                touch_count: a.touch_count,
                last_seen_at: a.last_seen_at,
                mr_refs,
                mr_refs_total,
                mr_refs_truncated,
            }
        })
        .collect();

    // Stable sort with full tie-breakers for deterministic output
    users.sort_by(|a, b| {
        b.touch_count
            .cmp(&a.touch_count)
            .then_with(|| b.last_seen_at.cmp(&a.last_seen_at))
            .then_with(|| a.username.cmp(&b.username))
    });

    let truncated = users.len() > limit;
    users.truncate(limit);

    Ok(OverlapResult {
        path_query: if pq.is_prefix {
            path.trim_end_matches('/').to_string()
        } else {
            pq.value.clone()
        },
        path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(),
        users,
        truncated,
    })
}

/// Format overlap role for display: "A", "R", or "A+R".
fn format_overlap_role(user: &OverlapUser) -> &'static str {
    match (user.author_touch_count > 0, user.review_touch_count > 0) {
        (true, true) => "A+R",
        (true, false) => "A",
        (false, true) => "R",
        (false, false) => "-",
    }
}

// ─── Human Output ────────────────────────────────────────────────────────────

pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {
    match result {
        WhoResult::Expert(r) => print_expert_human(r, project_path),
        WhoResult::Workload(r) => print_workload_human(r),
        WhoResult::Reviews(r) => print_reviews_human(r),
        WhoResult::Active(r) => print_active_human(r, project_path),
        WhoResult::Overlap(r) => print_overlap_human(r, project_path),
    }
}

/// Print a dim hint when results aggregate across all projects.
fn print_scope_hint(project_path: Option<&str>) {
    if project_path.is_none() {
        println!(
            "  {}",
            Theme::dim().render("(aggregated across all projects; use -p to scope)")
        );
    }
}

fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
    println!();
    println!(
        "{}",
        Theme::bold().render(&format!("Experts for {}", r.path_query))
    );
    println!("{}", "\u{2500}".repeat(60));
    println!(
        "  {}",
        Theme::dim().render(&format!(
            "(matching {} {})",
            r.path_match,
            if r.path_match == "exact" {
                "file"
            } else {
                "directory prefix"
            }
        ))
    );
    print_scope_hint(project_path);
    println!();

    if r.experts.is_empty() {
        println!(
            "  {}",
            Theme::dim().render("No experts found for this path.")
        );
        println!();
        return;
    }

    println!(
        "  {:<16} {:>6} {:>12} {:>6} {:>12}  {}  {}",
        Theme::bold().render("Username"),
        Theme::bold().render("Score"),
        Theme::bold().render("Reviewed(MRs)"),
        Theme::bold().render("Notes"),
        Theme::bold().render("Authored(MRs)"),
        Theme::bold().render("Last Seen"),
        Theme::bold().render("MR Refs"),
    );

    for expert in &r.experts {
        let reviews = if expert.review_mr_count > 0 {
            expert.review_mr_count.to_string()
        } else {
            "-".to_string()
        };
        let notes = if expert.review_note_count > 0 {
            expert.review_note_count.to_string()
        } else {
            "-".to_string()
        };
        let authored = if expert.author_mr_count > 0 {
            expert.author_mr_count.to_string()
        } else {
            "-".to_string()
        };
        let mr_str = expert
            .mr_refs
            .iter()
            .take(5)
            .cloned()
            .collect::<Vec<_>>()
            .join(", ");
        let overflow = if expert.mr_refs_total > 5 {
            format!(" +{}", expert.mr_refs_total - 5)
        } else {
            String::new()
        };
        println!(
            "  {:<16} {:>6} {:>12} {:>6} {:>12}  {:<12}{}{}",
            Theme::info().render(&format!("{} {}", Icons::user(), expert.username)),
            expert.score,
            reviews,
            notes,
            authored,
            render::format_relative_time(expert.last_seen_ms),
            if mr_str.is_empty() {
                String::new()
            } else {
                format!("  {mr_str}")
            },
            overflow,
        );

        // Print detail sub-rows when populated
        if let Some(details) = &expert.details {
            const MAX_DETAIL_DISPLAY: usize = 10;
            for d in details.iter().take(MAX_DETAIL_DISPLAY) {
                let notes_str = if d.note_count > 0 {
                    format!("{} notes", d.note_count)
                } else {
                    String::new()
                };
                println!(
                    "     {:<3} {:<30} {:>30}  {:>10}  {}",
                    Theme::dim().render(&d.role),
                    d.mr_ref,
                    render::truncate(&format!("\"{}\"", d.title), 30),
                    notes_str,
                    Theme::dim().render(&render::format_relative_time(d.last_activity_ms)),
                );
            }
            if details.len() > MAX_DETAIL_DISPLAY {
                println!(
                    "     {}",
                    Theme::dim().render(&format!("+{} more", details.len() - MAX_DETAIL_DISPLAY))
                );
            }
        }
    }
    if r.truncated {
        println!(
            "  {}",
            Theme::dim().render("(showing first -n; rerun with a higher --limit)")
        );
    }
    println!();
}

fn print_workload_human(r: &WorkloadResult) {
    println!();
    println!(
        "{}",
        Theme::bold().render(&format!(
            "{} {} -- Workload Summary",
            Icons::user(),
            r.username
        ))
    );
    println!("{}", "\u{2500}".repeat(60));

    if !r.assigned_issues.is_empty() {
        println!(
            "{}",
            render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len()))
        );
        for item in &r.assigned_issues {
            println!(
                "    {} {}  {}",
                Theme::info().render(&item.ref_),
                render::truncate(&item.title, 40),
                Theme::dim().render(&render::format_relative_time(item.updated_at)),
            );
        }
        if r.assigned_issues_truncated {
            println!(
                "    {}",
                Theme::dim().render("(truncated; rerun with a higher --limit)")
            );
        }
    }

    if !r.authored_mrs.is_empty() {
        println!(
            "{}",
            render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len()))
        );
        for mr in &r.authored_mrs {
            let draft = if mr.draft { " [draft]" } else { "" };
            println!(
                "    {} {}{}  {}",
                Theme::info().render(&mr.ref_),
                render::truncate(&mr.title, 35),
                Theme::dim().render(draft),
                Theme::dim().render(&render::format_relative_time(mr.updated_at)),
            );
        }
        if r.authored_mrs_truncated {
            println!(
                "    {}",
                Theme::dim().render("(truncated; rerun with a higher --limit)")
            );
        }
    }

    if !r.reviewing_mrs.is_empty() {
        println!(
            "{}",
            render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len()))
        );
        for mr in &r.reviewing_mrs {
            let author = mr
                .author_username
                .as_deref()
                .map(|a| format!(" by @{a}"))
                .unwrap_or_default();
            println!(
                "    {} {}{}  {}",
                Theme::info().render(&mr.ref_),
                render::truncate(&mr.title, 30),
                Theme::dim().render(&author),
                Theme::dim().render(&render::format_relative_time(mr.updated_at)),
            );
        }
        if r.reviewing_mrs_truncated {
            println!(
                "    {}",
                Theme::dim().render("(truncated; rerun with a higher --limit)")
            );
        }
    }

    if !r.unresolved_discussions.is_empty() {
        println!(
            "{}",
            render::section_divider(&format!(
                "Unresolved Discussions ({})",
                r.unresolved_discussions.len()
            ))
        );
        for disc in &r.unresolved_discussions {
            println!(
                "    {} {} {}  {}",
                Theme::dim().render(&disc.entity_type),
                Theme::info().render(&disc.ref_),
                render::truncate(&disc.entity_title, 35),
                Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
            );
        }
        if r.unresolved_discussions_truncated {
            println!(
                "    {}",
                Theme::dim().render("(truncated; rerun with a higher --limit)")
            );
        }
    }

    if r.assigned_issues.is_empty()
        && r.authored_mrs.is_empty()
        && r.reviewing_mrs.is_empty()
        && r.unresolved_discussions.is_empty()
    {
        println!();
        println!(
            "  {}",
            Theme::dim().render("No open work items found for this user.")
        );
    }

    println!();
}

fn print_reviews_human(r: &ReviewsResult) {
    println!();
    println!(
        "{}",
        Theme::bold().render(&format!(
            "{} {} -- Review Patterns",
            Icons::user(),
            r.username
        ))
    );
    println!("{}", "\u{2500}".repeat(60));
    println!();

    if r.total_diffnotes == 0 {
        println!(
            "  {}",
            Theme::dim().render("No review comments found for this user.")
        );
        println!();
        return;
    }

    println!(
        "  {} DiffNotes across {} MRs ({} categorized)",
        Theme::bold().render(&r.total_diffnotes.to_string()),
        Theme::bold().render(&r.mrs_reviewed.to_string()),
        Theme::bold().render(&r.categorized_count.to_string()),
    );
    println!();

    if !r.categories.is_empty() {
        println!(
            "  {:<16} {:>6} {:>6}",
            Theme::bold().render("Category"),
            Theme::bold().render("Count"),
            Theme::bold().render("%"),
        );

        for cat in &r.categories {
            println!(
                "  {:<16} {:>6} {:>5.1}%",
                Theme::info().render(&cat.name),
                cat.count,
                cat.percentage,
            );
        }
    }

    let uncategorized = r.total_diffnotes - r.categorized_count;
    if uncategorized > 0 {
        println!();
        println!(
            "  {} {} uncategorized (no **prefix** convention)",
            Theme::dim().render("Note:"),
            uncategorized,
        );
    }

    println!();
}

fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
    println!();
    println!(
        "{}",
        Theme::bold().render(&format!(
            "Active Discussions ({} unresolved in window)",
            r.total_unresolved_in_window
        ))
    );
    println!("{}", "\u{2500}".repeat(60));
    print_scope_hint(project_path);
    println!();

    if r.discussions.is_empty() {
        println!(
            "  {}",
            Theme::dim().render("No active unresolved discussions in this time window.")
        );
        println!();
        return;
    }

    for disc in &r.discussions {
        let prefix = if disc.entity_type == "MR" { "!" } else { "#" };
        let participants_str = disc
            .participants
            .iter()
            .map(|p| format!("@{p}"))
            .collect::<Vec<_>>()
            .join(", ");

        println!(
            "  {} {} {}  {} notes  {}",
            Theme::info().render(&format!("{prefix}{}", disc.entity_iid)),
            render::truncate(&disc.entity_title, 40),
            Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
            disc.note_count,
            Theme::dim().render(&disc.project_path),
        );
        if !participants_str.is_empty() {
            println!("    {}", Theme::dim().render(&participants_str));
        }
    }
    if r.truncated {
        println!(
            "  {}",
            Theme::dim().render("(showing first -n; rerun with a higher --limit)")
        );
    }
    println!();
}

fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
    println!();
    println!(
        "{}",
        Theme::bold().render(&format!("Overlap for {}", r.path_query))
    );
    println!("{}", "\u{2500}".repeat(60));
    println!(
        "  {}",
        Theme::dim().render(&format!(
            "(matching {} {})",
            r.path_match,
            if r.path_match == "exact" {
                "file"
            } else {
                "directory prefix"
            }
        ))
    );
    print_scope_hint(project_path);
    println!();

    if r.users.is_empty() {
        println!(
            "  {}",
            Theme::dim().render("No overlapping users found for this path.")
        );
        println!();
        return;
    }

    println!(
        "  {:<16} {:<6} {:>7}  {:<12}  {}",
        Theme::bold().render("Username"),
        Theme::bold().render("Role"),
        Theme::bold().render("MRs"),
        Theme::bold().render("Last Seen"),
        Theme::bold().render("MR Refs"),
    );

    for user in &r.users {
        let mr_str = user
            .mr_refs
            .iter()
            .take(5)
            .cloned()
            .collect::<Vec<_>>()
            .join(", ");
        let overflow = if user.mr_refs.len() > 5 {
            format!(" +{}", user.mr_refs.len() - 5)
        } else {
            String::new()
        };

        println!(
            "  {:<16} {:<6} {:>7}  {:<12}  {}{}",
            Theme::info().render(&format!("{} {}", Icons::user(), user.username)),
            format_overlap_role(user),
            user.touch_count,
            render::format_relative_time(user.last_seen_at),
            mr_str,
            overflow,
        );
    }
    if r.truncated {
        println!(
            "  {}",
            Theme::dim().render("(showing first -n; rerun with a higher --limit)")
        );
    }
    println!();
}

// ─── Robot JSON Output ───────────────────────────────────────────────────────

pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) {
    let (mode, data) = match &run.result {
        WhoResult::Expert(r) => ("expert", expert_to_json(r)),
        WhoResult::Workload(r) => ("workload", workload_to_json(r)),
        WhoResult::Reviews(r) => ("reviews", reviews_to_json(r)),
        WhoResult::Active(r) => ("active", active_to_json(r)),
        WhoResult::Overlap(r) => ("overlap", overlap_to_json(r)),
    };

    // Raw CLI args -- what the user typed
    let input = serde_json::json!({
        "target": args.target,
        "path": args.path,
        "project": args.project,
        "since": args.since,
        "limit": args.limit,
        "detail": args.detail,
        "as_of": args.as_of,
        "explain_score": args.explain_score,
        "include_bots": args.include_bots,
        "all_history": args.all_history,
    });

    // Resolved/computed values -- what actually ran
    let resolved_input = serde_json::json!({
        "mode": run.resolved_input.mode,
        "project_id": run.resolved_input.project_id,
        "project_path": run.resolved_input.project_path,
        "since_ms": run.resolved_input.since_ms,
        "since_iso": run.resolved_input.since_iso,
        "since_mode": run.resolved_input.since_mode,
        "limit": run.resolved_input.limit,
    });

    let output = WhoJsonEnvelope {
        ok: true,
        data: WhoJsonData {
            mode: mode.to_string(),
            input,
            resolved_input,
            result: data,
        },
        meta: RobotMeta { elapsed_ms },
    };

    let mut value = serde_json::to_value(&output).unwrap_or_else(|e| {
        serde_json::json!({"ok":false,"error":{"code":"INTERNAL_ERROR","message":format!("JSON serialization failed: {e}")}})
    });

    if let Some(f) = &args.fields {
        let preset_key = format!("who_{mode}");
        let expanded = crate::cli::robot::expand_fields_preset(f, &preset_key);
        // Each who mode uses a different array key; try all possible keys
        for key in &[
            "experts",
            "assigned_issues",
            "authored_mrs",
            "review_mrs",
            "categories",
            "discussions",
            "users",
        ] {
            crate::cli::robot::filter_fields(&mut value, key, &expanded);
        }
    }

    println!("{}", serde_json::to_string(&value).unwrap());
}

#[derive(Serialize)]
struct WhoJsonEnvelope {
    ok: bool,
    data: WhoJsonData,
    meta: RobotMeta,
}

#[derive(Serialize)]
struct WhoJsonData {
    mode: String,
    input: serde_json::Value,
    resolved_input: serde_json::Value,
    #[serde(flatten)]
    result: serde_json::Value,
}

fn expert_to_json(r: &ExpertResult) -> serde_json::Value {
    serde_json::json!({
        "path_query": r.path_query,
        "path_match": r.path_match,
        "scoring_model_version": 2,
        "truncated": r.truncated,
        "experts": r.experts.iter().map(|e| {
            let mut obj = serde_json::json!({
                "username": e.username,
                "score": e.score,
                "review_mr_count": e.review_mr_count,
                "review_note_count": e.review_note_count,
                "author_mr_count": e.author_mr_count,
                "last_seen_at": ms_to_iso(e.last_seen_ms),
                "mr_refs": e.mr_refs,
                "mr_refs_total": e.mr_refs_total,
                "mr_refs_truncated": e.mr_refs_truncated,
            });
            if let Some(raw) = e.score_raw {
                obj["score_raw"] = serde_json::json!(raw);
            }
            if let Some(comp) = &e.components {
                obj["components"] = serde_json::json!({
                    "author": comp.author,
                    "reviewer_participated": comp.reviewer_participated,
                    "reviewer_assigned": comp.reviewer_assigned,
                    "notes": comp.notes,
                });
            }
            if let Some(details) = &e.details {
                obj["details"] = serde_json::json!(details.iter().map(|d| serde_json::json!({
                    "mr_ref": d.mr_ref,
                    "title": d.title,
                    "role": d.role,
                    "note_count": d.note_count,
                    "last_activity_at": ms_to_iso(d.last_activity_ms),
                })).collect::<Vec<_>>());
            }
            obj
        }).collect::<Vec<_>>(),
    })
}

fn workload_to_json(r: &WorkloadResult) -> serde_json::Value {
    serde_json::json!({
        "username": r.username,
        "assigned_issues": r.assigned_issues.iter().map(|i| serde_json::json!({
            "iid": i.iid,
            "ref": i.ref_,
            "title": i.title,
            "project_path": i.project_path,
            "updated_at": ms_to_iso(i.updated_at),
        })).collect::<Vec<_>>(),
        "authored_mrs": r.authored_mrs.iter().map(|m| serde_json::json!({
            "iid": m.iid,
            "ref": m.ref_,
            "title": m.title,
            "draft": m.draft,
            "project_path": m.project_path,
            "updated_at": ms_to_iso(m.updated_at),
        })).collect::<Vec<_>>(),
        "reviewing_mrs": r.reviewing_mrs.iter().map(|m| serde_json::json!({
            "iid": m.iid,
            "ref": m.ref_,
            "title": m.title,
            "draft": m.draft,
            "project_path": m.project_path,
            "author_username": m.author_username,
            "updated_at": ms_to_iso(m.updated_at),
        })).collect::<Vec<_>>(),
        "unresolved_discussions": r.unresolved_discussions.iter().map(|d| serde_json::json!({
            "entity_type": d.entity_type,
            "entity_iid": d.entity_iid,
            "ref": d.ref_,
            "entity_title": d.entity_title,
            "project_path": d.project_path,
            "last_note_at": ms_to_iso(d.last_note_at),
        })).collect::<Vec<_>>(),
        "summary": {
            "assigned_issue_count": r.assigned_issues.len(),
            "authored_mr_count": r.authored_mrs.len(),
            "reviewing_mr_count": r.reviewing_mrs.len(),
            "unresolved_discussion_count": r.unresolved_discussions.len(),
        },
        "truncation": {
            "assigned_issues_truncated": r.assigned_issues_truncated,
            "authored_mrs_truncated": r.authored_mrs_truncated,
            "reviewing_mrs_truncated": r.reviewing_mrs_truncated,
            "unresolved_discussions_truncated": r.unresolved_discussions_truncated,
        }
    })
}

fn reviews_to_json(r: &ReviewsResult) -> serde_json::Value {
    serde_json::json!({
        "username": r.username,
        "total_diffnotes": r.total_diffnotes,
        "categorized_count": r.categorized_count,
        "mrs_reviewed": r.mrs_reviewed,
        "categories": r.categories.iter().map(|c| serde_json::json!({
            "name": c.name,
            "count": c.count,
            "percentage": (c.percentage * 10.0).round() / 10.0,
        })).collect::<Vec<_>>(),
    })
}

fn active_to_json(r: &ActiveResult) -> serde_json::Value {
    serde_json::json!({
        "total_unresolved_in_window": r.total_unresolved_in_window,
        "truncated": r.truncated,
        "discussions": r.discussions.iter().map(|d| serde_json::json!({
            "discussion_id": d.discussion_id,
            "entity_type": d.entity_type,
            "entity_iid": d.entity_iid,
            "entity_title": d.entity_title,
            "project_path": d.project_path,
            "last_note_at": ms_to_iso(d.last_note_at),
            "note_count": d.note_count,
            "participants": d.participants,
            "participants_total": d.participants_total,
            "participants_truncated": d.participants_truncated,
        })).collect::<Vec<_>>(),
    })
}

fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
    serde_json::json!({
        "path_query": r.path_query,
        "path_match": r.path_match,
        "truncated": r.truncated,
        "users": r.users.iter().map(|u| serde_json::json!({
            "username": u.username,
            "role": format_overlap_role(u),
            "author_touch_count": u.author_touch_count,
            "review_touch_count": u.review_touch_count,
            "touch_count": u.touch_count,
            "last_seen_at": ms_to_iso(u.last_seen_at),
            "mr_refs": u.mr_refs,
            "mr_refs_total": u.mr_refs_total,
            "mr_refs_truncated": u.mr_refs_truncated,
        })).collect::<Vec<_>>(),
    })
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
#[path = "who_tests.rs"]
mod tests;
