// ─── Query Functions ────────────────────────────────────────────────────────
//
// SQL queries powering the `lore me` dashboard.
// Each function takes &Connection, username, optional project scope,
// and returns Result<Vec<StructType>>.

use rusqlite::Connection;

use crate::core::error::Result;

use regex::Regex;
use std::collections::HashMap;

use super::types::{
    ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMention, MeMr, SinceCheckEvent,
    SinceCheckGroup,
};

/// Stale threshold: items with no activity for 30 days are marked "stale".
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;

// ─── Attention Reason ───────────────────────────────────────────────────────

/// Format a human-readable duration from a millisecond epoch to now.
/// Returns e.g. "3 hours", "2 days", "1 week".
fn relative_duration(ms_epoch: i64) -> String {
    let diff = crate::core::time::now_ms() - ms_epoch;
    if diff < 60_000 {
        return "moments".to_string();
    }
    let (n, unit) = match diff {
        d if d < 3_600_000 => (d / 60_000, "minute"),
        d if d < 86_400_000 => (d / 3_600_000, "hour"),
        d if d < 604_800_000 => (d / 86_400_000, "day"),
        d if d < 2_592_000_000 => (d / 604_800_000, "week"),
        d => (d / 2_592_000_000, "month"),
    };
    if n == 1 {
        format!("1 {unit}")
    } else {
        format!("{n} {unit}s")
    }
}

/// Build a human-readable reason explaining why the attention state was set.
pub(super) fn format_attention_reason(
    state: &AttentionState,
    my_ts: Option<i64>,
    others_ts: Option<i64>,
    any_ts: Option<i64>,
) -> String {
    match state {
        AttentionState::NotReady => "Draft with no reviewers assigned".to_string(),
        AttentionState::Stale => {
            if let Some(ts) = any_ts {
                format!("No activity for {}", relative_duration(ts))
            } else {
                "No activity for over 30 days".to_string()
            }
        }
        AttentionState::NeedsAttention => {
            let others_ago = others_ts
                .map(|ts| format!("{} ago", relative_duration(ts)))
                .unwrap_or_else(|| "recently".to_string());
            if let Some(ts) = my_ts {
                format!(
                    "Others replied {}; you last commented {} ago",
                    others_ago,
                    relative_duration(ts)
                )
            } else {
                format!("Others commented {}; you haven't replied", others_ago)
            }
        }
        AttentionState::AwaitingResponse => {
            if let Some(ts) = my_ts {
                format!("You replied {} ago; awaiting others", relative_duration(ts))
            } else {
                "Awaiting response from others".to_string()
            }
        }
        AttentionState::NotStarted => "No discussion yet".to_string(),
    }
}

// ─── Open Issues (AC-5.1, Task #7) ─────────────────────────────────────────

/// Query open issues assigned to the user via issue_assignees.
/// Returns issues sorted by attention state priority, then by most recently updated.
/// Attention state is computed inline using CTE-based note timestamp comparison.
pub fn query_open_issues(
    conn: &Connection,
    username: &str,
    project_ids: &[i64],
) -> Result<Vec<MeIssue>> {
    let project_clause = build_project_clause("i.project_id", project_ids);

    let sql = format!(
        "WITH note_ts AS (
            SELECT d.issue_id,
                MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
                MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
                MAX(n.created_at) AS any_ts
            FROM notes n
            JOIN discussions d ON n.discussion_id = d.id
            WHERE n.is_system = 0 AND d.issue_id IS NOT NULL
            GROUP BY d.issue_id
        )
        SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
            CASE
                WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
                    THEN 'stale'
                WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
                    THEN 'needs_attention'
                WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
                    THEN 'awaiting_response'
                ELSE 'not_started'
            END AS attention_state,
            nt.my_ts, nt.others_ts, nt.any_ts
        FROM issues i
        JOIN issue_assignees ia ON ia.issue_id = i.id
        JOIN projects p ON i.project_id = p.id
        LEFT JOIN note_ts nt ON nt.issue_id = i.id
        WHERE ia.username = ?1
          AND i.state = 'opened'
          AND (i.status_name COLLATE NOCASE IN ('In Progress', 'In Review') OR i.status_name IS NULL)
          {project_clause}
        ORDER BY
            CASE
                WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
                    AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms}))
                    THEN 0
                WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
                    THEN 1
                WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
                    AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms}))
                    THEN 2
                WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
                    THEN 3
                ELSE 1
            END,
            i.updated_at DESC",
        stale_ms = STALE_THRESHOLD_MS,
    );

    let params = build_params(username, project_ids);
    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mut stmt = conn.prepare(&sql)?;
    let rows = stmt.query_map(param_refs.as_slice(), |row| {
        let attention_str: String = row.get(6)?;
        let my_ts: Option<i64> = row.get(7)?;
        let others_ts: Option<i64> = row.get(8)?;
        let any_ts: Option<i64> = row.get(9)?;
        let state = parse_attention_state(&attention_str);
        let reason = format_attention_reason(&state, my_ts, others_ts, any_ts);
        Ok(MeIssue {
            iid: row.get(0)?,
            title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
            project_path: row.get(2)?,
            status_name: row.get(3)?,
            updated_at: row.get(4)?,
            web_url: row.get(5)?,
            attention_state: state,
            attention_reason: reason,
            labels: Vec::new(),
        })
    })?;

    let mut issues: Vec<MeIssue> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
    populate_issue_labels(conn, &mut issues)?;
    Ok(issues)
}

// ─── Authored MRs (AC-5.2, Task #8) ────────────────────────────────────────

/// Query open MRs authored by the user.
pub fn query_authored_mrs(
    conn: &Connection,
    username: &str,
    project_ids: &[i64],
) -> Result<Vec<MeMr>> {
    let project_clause = build_project_clause("m.project_id", project_ids);

    let sql = format!(
        "WITH note_ts AS (
            SELECT d.merge_request_id,
                MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
                MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
                MAX(n.created_at) AS any_ts
            FROM notes n
            JOIN discussions d ON n.discussion_id = d.id
            WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
            GROUP BY d.merge_request_id
        )
        SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
            m.updated_at, m.web_url,
            CASE
                WHEN m.draft = 1 AND NOT EXISTS (
                    SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
                ) THEN 'not_ready'
                WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
                    THEN 'stale'
                WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
                    THEN 'needs_attention'
                WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
                    THEN 'awaiting_response'
                ELSE 'not_started'
            END AS attention_state,
            nt.my_ts, nt.others_ts, nt.any_ts
        FROM merge_requests m
        JOIN projects p ON m.project_id = p.id
        LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
        WHERE m.author_username = ?1
          AND m.state = 'opened'
          {project_clause}
        ORDER BY
            CASE
                WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
                WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
                    AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0
                WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
                WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
                    AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2
                WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
                ELSE 1
            END,
            m.updated_at DESC",
        stale_ms = STALE_THRESHOLD_MS,
    );

    let params = build_params(username, project_ids);
    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mut stmt = conn.prepare(&sql)?;
    let rows = stmt.query_map(param_refs.as_slice(), |row| {
        let attention_str: String = row.get(7)?;
        let my_ts: Option<i64> = row.get(8)?;
        let others_ts: Option<i64> = row.get(9)?;
        let any_ts: Option<i64> = row.get(10)?;
        let state = parse_attention_state(&attention_str);
        let reason = format_attention_reason(&state, my_ts, others_ts, any_ts);
        Ok(MeMr {
            iid: row.get(0)?,
            title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
            project_path: row.get(2)?,
            draft: row.get::<_, i32>(3)? != 0,
            detailed_merge_status: row.get(4)?,
            updated_at: row.get(5)?,
            web_url: row.get(6)?,
            attention_state: state,
            attention_reason: reason,
            author_username: None,
            labels: Vec::new(),
        })
    })?;

    let mut mrs: Vec<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
    populate_mr_labels(conn, &mut mrs)?;
    Ok(mrs)
}

// ─── Reviewing MRs (AC-5.3, Task #9) ───────────────────────────────────────

/// Query open MRs where user is a reviewer.
pub fn query_reviewing_mrs(
    conn: &Connection,
    username: &str,
    project_ids: &[i64],
) -> Result<Vec<MeMr>> {
    let project_clause = build_project_clause("m.project_id", project_ids);

    let sql = format!(
        "WITH note_ts AS (
            SELECT d.merge_request_id,
                MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
                MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
                MAX(n.created_at) AS any_ts
            FROM notes n
            JOIN discussions d ON n.discussion_id = d.id
            WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
            GROUP BY d.merge_request_id
        )
        SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
            m.author_username, m.updated_at, m.web_url,
            CASE
                -- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists
                WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
                    THEN 'stale'
                WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
                    THEN 'needs_attention'
                WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
                    THEN 'awaiting_response'
                ELSE 'not_started'
            END AS attention_state,
            nt.my_ts, nt.others_ts, nt.any_ts
        FROM merge_requests m
        JOIN mr_reviewers r ON r.merge_request_id = m.id
        JOIN projects p ON m.project_id = p.id
        LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
        WHERE r.username = ?1
          AND m.state = 'opened'
          {project_clause}
        ORDER BY
            CASE
                WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
                    AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0
                WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
                WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
                    AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2
                WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
                ELSE 1
            END,
            m.updated_at DESC",
        stale_ms = STALE_THRESHOLD_MS,
    );

    let params = build_params(username, project_ids);
    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mut stmt = conn.prepare(&sql)?;
    let rows = stmt.query_map(param_refs.as_slice(), |row| {
        let attention_str: String = row.get(8)?;
        let my_ts: Option<i64> = row.get(9)?;
        let others_ts: Option<i64> = row.get(10)?;
        let any_ts: Option<i64> = row.get(11)?;
        let state = parse_attention_state(&attention_str);
        let reason = format_attention_reason(&state, my_ts, others_ts, any_ts);
        Ok(MeMr {
            iid: row.get(0)?,
            title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
            project_path: row.get(2)?,
            draft: row.get::<_, i32>(3)? != 0,
            detailed_merge_status: row.get(4)?,
            author_username: row.get(5)?,
            updated_at: row.get(6)?,
            web_url: row.get(7)?,
            attention_state: state,
            attention_reason: reason,
            labels: Vec::new(),
        })
    })?;

    let mut mrs: Vec<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
    populate_mr_labels(conn, &mut mrs)?;
    Ok(mrs)
}

// ─── Activity Feed (AC-5.4, Tasks #11-13) ──────────────────────────────────

/// Query activity events on items currently associated with the user.
/// Combines notes, state events, label events, milestone events, and
/// assignment/reviewer system notes into a unified feed sorted newest-first.
pub fn query_activity(
    conn: &Connection,
    username: &str,
    project_ids: &[i64],
    since_ms: i64,
) -> Result<Vec<MeActivityEvent>> {
    // Build project filter for activity sources.
    // Activity params: ?1=username, ?2=since_ms, ?3+=project_ids
    let project_clause = build_project_clause_at("p.id", project_ids, 3);

    // Build the "my items" subquery fragments for issue/MR association checks.
    // These ensure we only see activity on items associated with the user,
    // regardless of state (open, closed, or merged). Comments on merged MRs
    // and closed issues are still relevant (follow-up discussions, post-merge
    // questions, etc.).
    let my_issue_check = "EXISTS (
        SELECT 1 FROM issue_assignees ia
        WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
    )";
    let my_mr_check = "(
        EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
        OR EXISTS (SELECT 1 FROM mr_reviewers rv
            WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
    )";

    // Source 1: Human comments on my items
    let notes_sql = format!(
        "SELECT n.created_at, 'note',
            CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
            COALESCE(i.iid, m.iid),
            p.path_with_namespace,
            n.author_username,
            CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
            SUBSTR(n.body, 1, 200),
            NULL
        FROM notes n
        JOIN discussions d ON n.discussion_id = d.id
        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 n.is_system = 0
          AND n.created_at >= ?2
          {project_clause}
          AND (
            (d.issue_id IS NOT NULL AND {issue_check})
            OR (d.merge_request_id IS NOT NULL AND {mr_check})
          )",
        project_clause = project_clause,
        issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
        mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
    );

    // Source 2: State events
    let state_sql = format!(
        "SELECT e.created_at, 'status_change',
            CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
            COALESCE(i.iid, m.iid),
            p.path_with_namespace,
            e.actor_username,
            CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
            e.state,
            NULL
        FROM resource_state_events e
        JOIN projects p ON e.project_id = p.id
        LEFT JOIN issues i ON e.issue_id = i.id
        LEFT JOIN merge_requests m ON e.merge_request_id = m.id
        WHERE e.created_at >= ?2
          {project_clause}
          AND (
            (e.issue_id IS NOT NULL AND {issue_check})
            OR (e.merge_request_id IS NOT NULL AND {mr_check})
          )",
        project_clause = project_clause,
        issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
        mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
    );

    // Source 3: Label events
    let label_sql = format!(
        "SELECT e.created_at, 'label_change',
            CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
            COALESCE(i.iid, m.iid),
            p.path_with_namespace,
            e.actor_username,
            CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
            (e.action || ' ' || COALESCE(e.label_name, '(deleted)')),
            NULL
        FROM resource_label_events e
        JOIN projects p ON e.project_id = p.id
        LEFT JOIN issues i ON e.issue_id = i.id
        LEFT JOIN merge_requests m ON e.merge_request_id = m.id
        WHERE e.created_at >= ?2
          {project_clause}
          AND (
            (e.issue_id IS NOT NULL AND {issue_check})
            OR (e.merge_request_id IS NOT NULL AND {mr_check})
          )",
        project_clause = project_clause,
        issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
        mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
    );

    // Source 4: Milestone events
    let milestone_sql = format!(
        "SELECT e.created_at, 'milestone_change',
            CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
            COALESCE(i.iid, m.iid),
            p.path_with_namespace,
            e.actor_username,
            CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
            (e.action || ' ' || COALESCE(e.milestone_title, '(deleted)')),
            NULL
        FROM resource_milestone_events e
        JOIN projects p ON e.project_id = p.id
        LEFT JOIN issues i ON e.issue_id = i.id
        LEFT JOIN merge_requests m ON e.merge_request_id = m.id
        WHERE e.created_at >= ?2
          {project_clause}
          AND (
            (e.issue_id IS NOT NULL AND {issue_check})
            OR (e.merge_request_id IS NOT NULL AND {mr_check})
          )",
        project_clause = project_clause,
        issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
        mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
    );

    // Source 5: Assignment/reviewer system notes (AC-12)
    let assign_sql = format!(
        "SELECT n.created_at,
            CASE
                WHEN LOWER(n.body) LIKE '%assigned to @%' THEN 'assign'
                WHEN LOWER(n.body) LIKE '%unassigned @%' THEN 'unassign'
                WHEN LOWER(n.body) LIKE '%requested review from @%' THEN 'review_request'
                ELSE 'assign'
            END,
            CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
            COALESCE(i.iid, m.iid),
            p.path_with_namespace,
            n.author_username,
            CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
            n.body,
            NULL
        FROM notes n
        JOIN discussions d ON n.discussion_id = d.id
        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 n.is_system = 1
          AND n.created_at >= ?2
          {project_clause}
          AND (
            LOWER(n.body) LIKE '%assigned to @' || LOWER(?1) || '%'
            OR LOWER(n.body) LIKE '%unassigned @' || LOWER(?1) || '%'
            OR LOWER(n.body) LIKE '%requested review from @' || LOWER(?1) || '%'
          )
          AND (
            (d.issue_id IS NOT NULL AND {issue_check})
            OR (d.merge_request_id IS NOT NULL AND {mr_check})
          )",
        project_clause = project_clause,
        issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
        mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
    );

    let full_sql = format!(
        "{notes_sql}
         UNION ALL {state_sql}
         UNION ALL {label_sql}
         UNION ALL {milestone_sql}
         UNION ALL {assign_sql}
         ORDER BY 1 DESC
         LIMIT 100"
    );

    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
    params.push(Box::new(username.to_string()));
    params.push(Box::new(since_ms));
    for &pid in project_ids {
        params.push(Box::new(pid));
    }
    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mut stmt = conn.prepare(&full_sql)?;
    let rows = stmt.query_map(param_refs.as_slice(), |row| {
        let event_type_str: String = row.get(1)?;
        Ok(MeActivityEvent {
            timestamp: row.get(0)?,
            event_type: parse_event_type(&event_type_str),
            entity_type: row.get(2)?,
            entity_iid: row.get(3)?,
            project_path: row.get(4)?,
            actor: row.get(5)?,
            is_own: row.get::<_, i32>(6)? != 0,
            summary: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
            body_preview: row.get(8)?,
        })
    })?;

    let events: Vec<MeActivityEvent> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
    Ok(events)
}

// ─── Since Last Check (cursor-based inbox) ──────────────────────────────────

/// Raw row from the since-last-check UNION query.
struct RawSinceCheckRow {
    timestamp: i64,
    event_type: String,
    entity_type: String,
    entity_iid: i64,
    entity_title: String,
    project_path: String,
    actor: Option<String>,
    summary: String,
    body_preview: Option<String>,
    is_mention_source: bool,
    mention_body: Option<String>,
}

/// Query actionable events from others since `cursor_ms`.
/// Returns events from three sources:
/// 1. Others' comments on my items (any state)
/// 2. @mentions on any item (not restricted to my items)
/// 3. Assignment/review-request system notes mentioning me
pub fn query_since_last_check(
    conn: &Connection,
    username: &str,
    cursor_ms: i64,
) -> Result<Vec<SinceCheckGroup>> {
    // Build the "my items" subquery fragments (reused from activity).
    // No state filter: comments on closed/merged items are still actionable.
    let my_issue_check = "EXISTS (
        SELECT 1 FROM issue_assignees ia
        WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
    )";
    let my_mr_check = "(
        EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
        OR EXISTS (SELECT 1 FROM mr_reviewers rv
            WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
    )";

    // Source 1: Others' comments on my items (any state)
    let source1 = format!(
        "SELECT n.created_at, 'note',
            CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
            COALESCE(i.iid, m.iid),
            COALESCE(i.title, m.title),
            p.path_with_namespace,
            n.author_username,
            SUBSTR(n.body, 1, 200),
            NULL,
            0,
            NULL
        FROM notes n
        JOIN discussions d ON n.discussion_id = d.id
        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 n.is_system = 0
          AND n.created_at > ?2
          AND n.author_username != ?1
          AND (
            (d.issue_id IS NOT NULL AND {issue_check})
            OR (d.merge_request_id IS NOT NULL AND {mr_check})
          )",
        issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
        mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
    );

    // Source 2: @mentions on ANY item (not restricted to my items)
    // Word-boundary-aware matching to reduce false positives
    let source2 = format!(
        "SELECT n.created_at, 'mention_note',
            CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
            COALESCE(i.iid, m.iid),
            COALESCE(i.title, m.title),
            p.path_with_namespace,
            n.author_username,
            SUBSTR(n.body, 1, 200),
            NULL,
            1,
            n.body
        FROM notes n
        JOIN discussions d ON n.discussion_id = d.id
        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 n.is_system = 0
          AND n.created_at > ?2
          AND n.author_username != ?1
          AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
          AND NOT (
            (d.issue_id IS NOT NULL AND {issue_check})
            OR (d.merge_request_id IS NOT NULL AND {mr_check})
          )",
        issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
        mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
    );

    // Source 3: Assignment/review-request system notes mentioning me
    let source3 = "SELECT n.created_at,
            CASE
                WHEN LOWER(n.body) LIKE '%assigned to @%' THEN 'assign'
                WHEN LOWER(n.body) LIKE '%unassigned @%' THEN 'unassign'
                WHEN LOWER(n.body) LIKE '%requested review from @%' THEN 'review_request'
                ELSE 'assign'
            END,
            CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
            COALESCE(i.iid, m.iid),
            COALESCE(i.title, m.title),
            p.path_with_namespace,
            n.author_username,
            n.body,
            NULL,
            0,
            NULL
        FROM notes n
        JOIN discussions d ON n.discussion_id = d.id
        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 n.is_system = 1
          AND n.created_at > ?2
          AND n.author_username != ?1
          AND (
            LOWER(n.body) LIKE '%assigned to @' || LOWER(?1) || '%'
            OR LOWER(n.body) LIKE '%unassigned @' || LOWER(?1) || '%'
            OR LOWER(n.body) LIKE '%requested review from @' || LOWER(?1) || '%'
          )"
    .to_string();

    let full_sql = format!(
        "{source1}
         UNION ALL {source2}
         UNION ALL {source3}
         ORDER BY 1 DESC
         LIMIT 200"
    );

    let params: Vec<Box<dyn rusqlite::types::ToSql>> =
        vec![Box::new(username.to_string()), Box::new(cursor_ms)];
    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mut stmt = conn.prepare(&full_sql)?;
    let rows = stmt.query_map(param_refs.as_slice(), |row| {
        Ok(RawSinceCheckRow {
            timestamp: row.get(0)?,
            event_type: row.get(1)?,
            entity_type: row.get(2)?,
            entity_iid: row.get(3)?,
            entity_title: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
            project_path: row.get(5)?,
            actor: row.get(6)?,
            summary: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
            body_preview: row.get(8)?,
            is_mention_source: row.get::<_, i32>(9)? != 0,
            mention_body: row.get(10)?,
        })
    })?;

    let mention_re = build_exact_mention_regex(username);
    let raw_events: Vec<RawSinceCheckRow> = rows
        .collect::<std::result::Result<Vec<_>, _>>()?
        .into_iter()
        .filter(|row| {
            !row.is_mention_source
                || row
                    .mention_body
                    .as_deref()
                    .is_some_and(|body| contains_exact_mention(body, &mention_re))
        })
        .collect();
    Ok(group_since_check_events(raw_events))
}

/// Group flat event rows by entity, sort groups newest-first, events within oldest-first.
fn group_since_check_events(rows: Vec<RawSinceCheckRow>) -> Vec<SinceCheckGroup> {
    // Key: (entity_type, entity_iid, project_path)
    let mut groups: HashMap<(String, i64, String), SinceCheckGroup> = HashMap::new();

    for row in rows {
        let key = (
            row.entity_type.clone(),
            row.entity_iid,
            row.project_path.clone(),
        );
        let group = groups.entry(key).or_insert_with(|| SinceCheckGroup {
            entity_type: row.entity_type.clone(),
            entity_iid: row.entity_iid,
            entity_title: row.entity_title.clone(),
            project_path: row.project_path.clone(),
            events: Vec::new(),
            latest_timestamp: 0,
        });

        if row.timestamp > group.latest_timestamp {
            group.latest_timestamp = row.timestamp;
        }

        group.events.push(SinceCheckEvent {
            timestamp: row.timestamp,
            event_type: parse_event_type(&row.event_type),
            actor: row.actor,
            summary: row.summary,
            body_preview: row.body_preview,
        });
    }

    let mut result: Vec<SinceCheckGroup> = groups.into_values().collect();
    // Sort groups newest-first
    result.sort_by_key(|g| std::cmp::Reverse(g.latest_timestamp));
    // Sort events within each group oldest-first (read top-to-bottom)
    for group in &mut result {
        group.events.sort_by_key(|e| e.timestamp);
    }
    result
}

// ─── Mentioned In (issues/MRs where user is @mentioned but not formally associated)

/// Raw row from the mentioned-in query.
struct RawMentionRow {
    entity_type: String,
    iid: i64,
    title: String,
    project_path: String,
    state: String,
    updated_at: i64,
    web_url: Option<String>,
    my_ts: Option<i64>,
    others_ts: Option<i64>,
    any_ts: Option<i64>,
    mention_body: String,
}

fn build_mentioned_in_sql(project_clause: &str) -> String {
    format!(
        "WITH candidate_issues AS MATERIALIZED (
            SELECT i.id, i.iid, i.title, p.path_with_namespace, i.state,
                   i.updated_at, i.web_url
            FROM issues i
            JOIN projects p ON i.project_id = p.id
            WHERE (i.state = 'opened' OR (i.state = 'closed' AND i.updated_at > ?2))
              AND (i.author_username IS NULL OR i.author_username != ?1)
              AND NOT EXISTS (
                SELECT 1 FROM issue_assignees ia
                WHERE ia.issue_id = i.id AND ia.username = ?1
              )
              {project_clause}
        ),
        candidate_mrs AS MATERIALIZED (
            SELECT m.id, m.iid, m.title, p.path_with_namespace, m.state,
                   m.updated_at, m.web_url
            FROM merge_requests m
            JOIN projects p ON m.project_id = p.id
            WHERE (m.state = 'opened'
                   OR (m.state IN ('merged', 'closed') AND m.updated_at > ?2))
              AND m.author_username != ?1
              AND NOT EXISTS (
                SELECT 1 FROM mr_reviewers rv
                WHERE rv.merge_request_id = m.id AND rv.username = ?1
              )
              {project_clause}
        ),
        note_ts_issue AS MATERIALIZED (
            SELECT d.issue_id,
                MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
                MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
                MAX(n.created_at) AS any_ts
            FROM notes n
            JOIN discussions d ON n.discussion_id = d.id
            JOIN candidate_issues ci ON ci.id = d.issue_id
            WHERE n.is_system = 0
            GROUP BY d.issue_id
        ),
        note_ts_mr AS MATERIALIZED (
            SELECT d.merge_request_id,
                MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
                MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
                MAX(n.created_at) AS any_ts
            FROM notes n
            JOIN discussions d ON n.discussion_id = d.id
            JOIN candidate_mrs cm ON cm.id = d.merge_request_id
            WHERE n.is_system = 0
            GROUP BY d.merge_request_id
        )
        -- Issue mentions (scoped to candidate entities only)
        SELECT 'issue', ci.iid, ci.title, ci.path_with_namespace, ci.state,
               ci.updated_at, ci.web_url,
               nt.my_ts, nt.others_ts, nt.any_ts,
               n.body
        FROM notes n
        JOIN discussions d ON n.discussion_id = d.id
        JOIN candidate_issues ci ON ci.id = d.issue_id
        LEFT JOIN note_ts_issue nt ON nt.issue_id = ci.id
        WHERE n.is_system = 0
          AND n.author_username != ?1
          AND n.created_at > ?3
          AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
        UNION ALL
        -- MR mentions (scoped to candidate entities only)
        SELECT 'mr', cm.iid, cm.title, cm.path_with_namespace, cm.state,
               cm.updated_at, cm.web_url,
               nt.my_ts, nt.others_ts, nt.any_ts,
               n.body
        FROM notes n
        JOIN discussions d ON n.discussion_id = d.id
        JOIN candidate_mrs cm ON cm.id = d.merge_request_id
        LEFT JOIN note_ts_mr nt ON nt.merge_request_id = cm.id
        WHERE n.is_system = 0
          AND n.author_username != ?1
          AND n.created_at > ?3
          AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
        ORDER BY 6 DESC
        LIMIT 500",
    )
}

/// Query issues and MRs where the user is @mentioned but not assigned/authored/reviewing.
///
/// Includes open items unconditionally, plus recently-closed/merged items
/// (where `updated_at > recency_cutoff_ms`). Only considers mentions in notes
/// created after `mention_cutoff_ms` (typically 30 days ago).
///
/// Returns deduplicated results sorted by attention priority then recency.
pub fn query_mentioned_in(
    conn: &Connection,
    username: &str,
    project_ids: &[i64],
    recency_cutoff_ms: i64,
    mention_cutoff_ms: i64,
) -> Result<Vec<MeMention>> {
    let project_clause = build_project_clause_at("p.id", project_ids, 4);
    // Materialized CTEs avoid pathological query plans for project-scoped mentions.
    let sql = build_mentioned_in_sql(&project_clause);

    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
    params.push(Box::new(username.to_string()));
    params.push(Box::new(recency_cutoff_ms));
    params.push(Box::new(mention_cutoff_ms));
    for &pid in project_ids {
        params.push(Box::new(pid));
    }
    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();

    let mention_re = build_exact_mention_regex(username);

    let mut stmt = conn.prepare(&sql)?;
    let rows = stmt.query_map(param_refs.as_slice(), |row| {
        Ok(RawMentionRow {
            entity_type: row.get(0)?,
            iid: row.get(1)?,
            title: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
            project_path: row.get(3)?,
            state: row.get(4)?,
            updated_at: row.get(5)?,
            web_url: row.get(6)?,
            my_ts: row.get(7)?,
            others_ts: row.get(8)?,
            any_ts: row.get(9)?,
            mention_body: row.get::<_, Option<String>>(10)?.unwrap_or_default(),
        })
    })?;

    let raw: Vec<RawMentionRow> = rows.collect::<std::result::Result<Vec<_>, _>>()?;

    // Post-filter with exact mention regex and deduplicate by entity
    let mut seen: HashMap<(String, i64, String), RawMentionRow> = HashMap::new();
    for row in raw {
        if !contains_exact_mention(&row.mention_body, &mention_re) {
            continue;
        }
        let key = (row.entity_type.clone(), row.iid, row.project_path.clone());
        // Keep the first occurrence (most recent due to ORDER BY updated_at DESC)
        seen.entry(key).or_insert(row);
    }

    let mut mentions: Vec<MeMention> = seen
        .into_values()
        .map(|row| {
            let state = compute_mention_attention(row.my_ts, row.others_ts, row.any_ts);
            let reason = format_attention_reason(&state, row.my_ts, row.others_ts, row.any_ts);
            MeMention {
                entity_type: row.entity_type,
                iid: row.iid,
                title: row.title,
                project_path: row.project_path,
                state: row.state,
                attention_state: state,
                attention_reason: reason,
                updated_at: row.updated_at,
                web_url: row.web_url,
            }
        })
        .collect();

    // Sort by attention priority (needs_attention first), then by updated_at DESC
    mentions.sort_by(|a, b| {
        a.attention_state
            .cmp(&b.attention_state)
            .then_with(|| b.updated_at.cmp(&a.updated_at))
    });

    Ok(mentions)
}

/// Compute attention state for a mentioned-in item.
/// Same logic as the other sections, but without the not_ready variant
/// since it's less relevant for mention-only items.
fn compute_mention_attention(
    my_ts: Option<i64>,
    others_ts: Option<i64>,
    any_ts: Option<i64>,
) -> AttentionState {
    // Stale check
    if let Some(ts) = any_ts
        && ts < crate::core::time::now_ms() - STALE_THRESHOLD_MS
    {
        return AttentionState::Stale;
    }
    // Others commented after me (or I never engaged but others have)
    if let Some(ots) = others_ts
        && my_ts.is_none_or(|mts| ots > mts)
    {
        return AttentionState::NeedsAttention;
    }
    // I replied and my reply is >= others' latest
    if let Some(mts) = my_ts
        && mts >= others_ts.unwrap_or(0)
    {
        return AttentionState::AwaitingResponse;
    }
    AttentionState::NotStarted
}

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

/// Parse attention state string from SQL CASE result.
fn parse_attention_state(s: &str) -> AttentionState {
    match s {
        "needs_attention" => AttentionState::NeedsAttention,
        "not_started" => AttentionState::NotStarted,
        "awaiting_response" => AttentionState::AwaitingResponse,
        "stale" => AttentionState::Stale,
        "not_ready" => AttentionState::NotReady,
        _ => AttentionState::NotStarted,
    }
}

/// Parse activity event type string from SQL.
fn parse_event_type(s: &str) -> ActivityEventType {
    match s {
        "note" => ActivityEventType::Note,
        "mention_note" => ActivityEventType::Note,
        "status_change" => ActivityEventType::StatusChange,
        "label_change" => ActivityEventType::LabelChange,
        "assign" => ActivityEventType::Assign,
        "unassign" => ActivityEventType::Unassign,
        "review_request" => ActivityEventType::ReviewRequest,
        "milestone_change" => ActivityEventType::MilestoneChange,
        _ => ActivityEventType::Note,
    }
}

fn build_exact_mention_regex(username: &str) -> Regex {
    let escaped = regex::escape(username);
    let pattern = format!(r"(?i)@{escaped}");
    Regex::new(&pattern).expect("mention regex must compile")
}

fn contains_exact_mention(body: &str, mention_re: &Regex) -> bool {
    for m in mention_re.find_iter(body) {
        let start = m.start();
        let end = m.end();

        let prev = body[..start].chars().next_back();
        if prev.is_some_and(is_username_char) {
            continue;
        }

        if let Some(next) = body[end..].chars().next() {
            // Reject domain-like continuations such as "@alice.com"
            if next == '.' {
                let after_dot = body[end + next.len_utf8()..].chars().next();
                if after_dot.is_some_and(is_username_char) {
                    continue;
                }
            }

            if is_username_char(next) {
                continue;
            }
        }

        return true;
    }

    false
}

fn is_username_char(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}

/// Build a SQL clause for project ID filtering.
/// `start_idx` is the 1-based parameter index for the first project ID.
/// Returns empty string when no filter is needed (all projects).
fn build_project_clause_at(column: &str, project_ids: &[i64], start_idx: usize) -> String {
    match project_ids.len() {
        0 => String::new(),
        1 => format!("AND {column} = ?{start_idx}"),
        n => {
            let placeholders: Vec<String> = (0..n).map(|i| format!("?{}", start_idx + i)).collect();
            format!("AND {column} IN ({})", placeholders.join(","))
        }
    }
}

/// Convenience: project clause starting at param index 2 (after username at ?1).
fn build_project_clause(column: &str, project_ids: &[i64]) -> String {
    build_project_clause_at(column, project_ids, 2)
}

/// Build the parameter vector: username first, then project IDs.
fn build_params(username: &str, project_ids: &[i64]) -> Vec<Box<dyn rusqlite::types::ToSql>> {
    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
    params.push(Box::new(username.to_string()));
    for &pid in project_ids {
        params.push(Box::new(pid));
    }
    params
}

/// Populate labels for issues via cached per-item queries.
fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> {
    if issues.is_empty() {
        return Ok(());
    }
    for issue in issues.iter_mut() {
        let mut stmt = conn.prepare_cached(
            "SELECT l.name FROM labels l
             JOIN issue_labels il ON l.id = il.label_id
             JOIN issues i ON il.issue_id = i.id
             JOIN projects p ON i.project_id = p.id
             WHERE i.iid = ?1 AND p.path_with_namespace = ?2
             ORDER BY l.name",
        )?;
        let labels: Vec<String> = stmt
            .query_map(rusqlite::params![issue.iid, issue.project_path], |row| {
                row.get(0)
            })?
            .collect::<std::result::Result<Vec<_>, _>>()?;
        issue.labels = labels;
    }
    Ok(())
}

/// Populate labels for MRs via cached per-item queries.
fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> {
    if mrs.is_empty() {
        return Ok(());
    }
    for mr in mrs.iter_mut() {
        let mut stmt = conn.prepare_cached(
            "SELECT l.name FROM labels l
             JOIN mr_labels ml ON l.id = ml.label_id
             JOIN merge_requests m ON ml.merge_request_id = m.id
             JOIN projects p ON m.project_id = p.id
             WHERE m.iid = ?1 AND p.path_with_namespace = ?2
             ORDER BY l.name",
        )?;
        let labels: Vec<String> = stmt
            .query_map(rusqlite::params![mr.iid, mr.project_path], |row| row.get(0))?
            .collect::<std::result::Result<Vec<_>, _>>()?;
        mr.labels = labels;
    }
    Ok(())
}

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

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