use std::collections::{HashMap, HashSet};

use rusqlite::Connection;

use crate::cli::render::{self, Icons, Theme};
use crate::core::config::ScoringConfig;
use crate::core::error::Result;
use crate::core::path_resolver::{PathQuery, build_path_query};
use crate::core::time::ms_to_iso;

use super::types::*;

pub(super) 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(super) 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,
    })
}

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
pub(super) 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>.
pub(super) 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)
}

pub(super) 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"
            }
        ))
    );
    super::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()
                };
                let title_budget = render::flex_width(55, 20);
                println!(
                    "     {:<3} {}  {}  {:>10}  {}",
                    Theme::dim().render(&d.role),
                    d.mr_ref,
                    render::truncate(&format!("\"{}\"", d.title), title_budget),
                    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!();
}

pub(super) 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<_>>(),
    })
}
