use super::*;
use crate::cli::commands::me::types::{ActivityEventType, AttentionState};
use crate::core::time::now_ms;
use crate::test_support::{insert_project, setup_test_db};
use rusqlite::Connection;

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

fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) {
    insert_issue_with_status(
        conn,
        id,
        project_id,
        iid,
        author,
        "opened",
        Some("In Progress"),
    );
}

fn insert_issue_with_state(
    conn: &Connection,
    id: i64,
    project_id: i64,
    iid: i64,
    author: &str,
    state: &str,
) {
    // For closed issues, don't set status_name (they won't appear in dashboard anyway)
    let status_name = if state == "opened" {
        Some("In Progress")
    } else {
        None
    };
    insert_issue_with_status(conn, id, project_id, iid, author, state, status_name);
}

#[allow(clippy::too_many_arguments)]
fn insert_issue_with_status(
    conn: &Connection,
    id: i64,
    project_id: i64,
    iid: i64,
    author: &str,
    state: &str,
    status_name: Option<&str>,
) {
    let ts = now_ms();
    conn.execute(
        "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, status_name, author_username, created_at, updated_at, last_seen_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
        rusqlite::params![
            id,
            id * 10,
            project_id,
            iid,
            format!("Issue {iid}"),
            state,
            status_name,
            author,
            ts,
            ts,
            ts
        ],
    )
    .unwrap();
}

fn insert_assignee(conn: &Connection, issue_id: i64, username: &str) {
    conn.execute(
        "INSERT INTO issue_assignees (issue_id, username) VALUES (?1, ?2)",
        rusqlite::params![issue_id, username],
    )
    .unwrap();
}

#[allow(clippy::too_many_arguments)]
fn insert_mr(
    conn: &Connection,
    id: i64,
    project_id: i64,
    iid: i64,
    author: &str,
    state: &str,
    draft: bool,
) {
    let ts = now_ms();
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, draft, last_seen_at, updated_at, created_at, merged_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
        rusqlite::params![
            id,
            id * 10,
            project_id,
            iid,
            format!("MR {iid}"),
            author,
            state,
            i32::from(draft),
            ts,
            ts,
            ts,
            if state == "merged" { Some(ts) } else { None::<i64> }
        ],
    )
    .unwrap();
}

fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) {
    conn.execute(
        "INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)",
        rusqlite::params![mr_id, username],
    )
    .unwrap();
}

fn insert_discussion(
    conn: &Connection,
    id: i64,
    project_id: i64,
    mr_id: Option<i64>,
    issue_id: Option<i64>,
) {
    let noteable_type = if mr_id.is_some() {
        "MergeRequest"
    } else {
        "Issue"
    };
    let ts = now_ms();
    conn.execute(
        "INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, issue_id, noteable_type, resolvable, resolved, last_seen_at, last_note_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 0, ?7, ?8)",
        rusqlite::params![
            id,
            format!("disc-{id}"),
            project_id,
            mr_id,
            issue_id,
            noteable_type,
            ts,
            ts
        ],
    )
    .unwrap();
}

#[allow(clippy::too_many_arguments)]
fn insert_note_at(
    conn: &Connection,
    id: i64,
    discussion_id: i64,
    project_id: i64,
    author: &str,
    is_system: bool,
    body: &str,
    created_at: i64,
) {
    conn.execute(
        "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at)
         VALUES (?1, ?2, ?3, ?4, 'DiscussionNote', ?5, ?6, ?7, ?8, ?9, ?10)",
        rusqlite::params![
            id,
            id * 10,
            discussion_id,
            project_id,
            i32::from(is_system),
            author,
            body,
            created_at,
            created_at,
            now_ms()
        ],
    )
    .unwrap();
}

#[allow(clippy::too_many_arguments)]
fn insert_state_event(
    conn: &Connection,
    id: i64,
    project_id: i64,
    issue_id: Option<i64>,
    mr_id: Option<i64>,
    state: &str,
    actor: &str,
    created_at: i64,
) {
    conn.execute(
        "INSERT INTO resource_state_events (id, gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
        rusqlite::params![id, id * 10, project_id, issue_id, mr_id, state, actor, created_at],
    )
    .unwrap();
}

#[allow(clippy::too_many_arguments)]
fn insert_label_event(
    conn: &Connection,
    id: i64,
    project_id: i64,
    issue_id: Option<i64>,
    mr_id: Option<i64>,
    action: &str,
    label_name: &str,
    actor: &str,
    created_at: i64,
) {
    conn.execute(
        "INSERT INTO resource_label_events (id, gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
        rusqlite::params![
            id,
            id * 10,
            project_id,
            issue_id,
            mr_id,
            action,
            label_name,
            actor,
            created_at
        ],
    )
    .unwrap();
}

// ─── Open Issues Tests (Task #7) ───────────────────────────────────────────

#[test]
fn open_issues_returns_assigned_only() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_issue(&conn, 11, 1, 43, "someone");
    // Only assign issue 42 to alice
    insert_assignee(&conn, 10, "alice");

    let results = query_open_issues(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].iid, 42);
}

#[test]
fn open_issues_excludes_closed() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_issue_with_state(&conn, 11, 1, 43, "someone", "closed");
    insert_assignee(&conn, 10, "alice");
    insert_assignee(&conn, 11, "alice");

    let results = query_open_issues(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].iid, 42);
}

#[test]
fn open_issues_project_filter() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo-a");
    insert_project(&conn, 2, "group/repo-b");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_issue(&conn, 11, 2, 43, "someone");
    insert_assignee(&conn, 10, "alice");
    insert_assignee(&conn, 11, "alice");

    // Filter to project 1 only
    let results = query_open_issues(&conn, "alice", &[1]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].project_path, "group/repo-a");
}

#[test]
fn open_issues_empty_when_unassigned() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "alice");
    // alice authored but is NOT assigned
    let results = query_open_issues(&conn, "alice", &[]).unwrap();
    assert!(results.is_empty());
}

// ─── Attention State Tests (Task #10) ──────────────────────────────────────

#[test]
fn attention_state_not_started_no_notes() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let results = query_open_issues(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].attention_state, AttentionState::NotStarted);
}

#[test]
fn attention_state_needs_attention_others_replied() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    // alice comments first, then bob replies after
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t1 = now_ms() - 5000;
    let t2 = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t1);
    insert_note_at(&conn, 201, disc_id, 1, "bob", false, "reply", t2);

    let results = query_open_issues(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].attention_state, AttentionState::NeedsAttention);
}

#[test]
fn attention_state_awaiting_response() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t1 = now_ms() - 5000;
    let t2 = now_ms() - 1000;
    // bob first, then alice replies (alice's latest >= others' latest)
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "question", t1);
    insert_note_at(&conn, 201, disc_id, 1, "alice", false, "my reply", t2);

    let results = query_open_issues(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse);
}

// ─── Authored MRs Tests (Task #8) ─────────────────────────────────────────

#[test]
fn authored_mrs_returns_own_only() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
    insert_mr(&conn, 11, 1, 100, "bob", "opened", false);

    let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].iid, 99);
}

#[test]
fn authored_mrs_excludes_merged() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
    insert_mr(&conn, 11, 1, 100, "alice", "merged", false);

    let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].iid, 99);
}

#[test]
fn authored_mrs_project_filter() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo-a");
    insert_project(&conn, 2, "group/repo-b");
    insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
    insert_mr(&conn, 11, 2, 100, "alice", "opened", false);

    let results = query_authored_mrs(&conn, "alice", &[2]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].project_path, "group/repo-b");
}

#[test]
fn authored_mr_not_ready_when_draft_no_reviewers() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "alice", "opened", true);
    // No reviewers added

    let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert!(results[0].draft);
    assert_eq!(results[0].attention_state, AttentionState::NotReady);
}

#[test]
fn authored_mr_not_ready_overridden_when_has_reviewers() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "alice", "opened", true);
    insert_reviewer(&conn, 10, "bob");

    let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    // Draft with reviewers -> not_started (not not_ready), since no one has commented
    assert_eq!(results[0].attention_state, AttentionState::NotStarted);
}

// ─── Reviewing MRs Tests (Task #9) ────────────────────────────────────────

#[test]
fn reviewing_mrs_returns_reviewer_items() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
    insert_mr(&conn, 11, 1, 100, "charlie", "opened", false);
    insert_reviewer(&conn, 10, "alice");
    // alice is NOT a reviewer of MR 100

    let results = query_reviewing_mrs(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].iid, 99);
}

#[test]
fn reviewing_mrs_includes_author_username() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
    insert_reviewer(&conn, 10, "alice");

    let results = query_reviewing_mrs(&conn, "alice", &[]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].author_username, Some("bob".to_string()));
}

#[test]
fn reviewing_mrs_project_filter() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo-a");
    insert_project(&conn, 2, "group/repo-b");
    insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
    insert_mr(&conn, 11, 2, 100, "bob", "opened", false);
    insert_reviewer(&conn, 10, "alice");
    insert_reviewer(&conn, 11, "alice");

    let results = query_reviewing_mrs(&conn, "alice", &[1]).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].project_path, "group/repo-a");
}

// ─── Activity Feed Tests (Tasks #11-13) ────────────────────────────────────

#[test]
fn activity_note_on_assigned_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].event_type, ActivityEventType::Note);
    assert_eq!(results[0].entity_iid, 42);
    assert_eq!(results[0].entity_type, "issue");
    assert!(!results[0].is_own);
}

#[test]
fn activity_note_on_authored_mr() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "alice", "opened", false);

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, Some(10), None);
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "nice work", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].event_type, ActivityEventType::Note);
    assert_eq!(results[0].entity_type, "mr");
    assert_eq!(results[0].entity_iid, 99);
}

#[test]
fn activity_state_event_on_my_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let t = now_ms() - 1000;
    insert_state_event(&conn, 300, 1, Some(10), None, "closed", "bob", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].event_type, ActivityEventType::StatusChange);
    assert_eq!(results[0].summary, "closed");
}

#[test]
fn activity_label_event_on_my_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let t = now_ms() - 1000;
    insert_label_event(&conn, 400, 1, Some(10), None, "add", "bug", "bob", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].event_type, ActivityEventType::LabelChange);
    assert!(results[0].summary.contains("bug"));
}

#[test]
fn activity_excludes_unassociated_items() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    // Issue NOT assigned to alice
    insert_issue(&conn, 10, 1, 42, "someone");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert!(
        results.is_empty(),
        "should not see activity on unassigned issues"
    );
}

#[test]
fn activity_since_filter() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let old_t = now_ms() - 100_000_000; // ~1 day ago
    let recent_t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "old comment", old_t);
    insert_note_at(
        &conn,
        201,
        disc_id,
        1,
        "bob",
        false,
        "new comment",
        recent_t,
    );

    // since = 50 seconds ago, should only get the recent note
    let since = now_ms() - 50_000;
    let results = query_activity(&conn, "alice", &[], since).unwrap();
    assert_eq!(results.len(), 1);
    // Notes no longer duplicate body into body_preview (summary carries the content)
    assert_eq!(results[0].body_preview, None);
    assert_eq!(results[0].summary, "new comment");
}

#[test]
fn activity_project_filter() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo-a");
    insert_project(&conn, 2, "group/repo-b");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_issue(&conn, 11, 2, 43, "someone");
    insert_assignee(&conn, 10, "alice");
    insert_assignee(&conn, 11, "alice");

    let disc_a = 100;
    let disc_b = 101;
    insert_discussion(&conn, disc_a, 1, None, Some(10));
    insert_discussion(&conn, disc_b, 2, None, Some(11));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_a, 1, "bob", false, "comment a", t);
    insert_note_at(&conn, 201, disc_b, 2, "bob", false, "comment b", t);

    // Filter to project 1 only
    let results = query_activity(&conn, "alice", &[1], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].project_path, "group/repo-a");
}

#[test]
fn activity_sorted_newest_first() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t1 = now_ms() - 5000;
    let t2 = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "first", t1);
    insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "second", t2);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 2);
    assert!(
        results[0].timestamp >= results[1].timestamp,
        "should be sorted newest first"
    );
}

#[test]
fn activity_is_own_flag() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert!(results[0].is_own);
}

// ─── Activity on Closed/Merged Items ─────────────────────────────────────────

#[test]
fn activity_note_on_merged_authored_mr() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "alice", "merged", false);

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, Some(10), None);
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "follow-up question",
        t,
    );

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(
        results.len(),
        1,
        "should see activity on merged MR authored by user"
    );
    assert_eq!(results[0].entity_iid, 99);
    assert_eq!(results[0].entity_type, "mr");
}

#[test]
fn activity_note_on_closed_mr_as_reviewer() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "bob", "closed", false);
    insert_reviewer(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, Some(10), None);
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "can you re-check?", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(
        results.len(),
        1,
        "should see activity on closed MR where user is reviewer"
    );
}

#[test]
fn activity_note_on_closed_assigned_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
    insert_assignee(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "reopening discussion",
        t,
    );

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(
        results.len(),
        1,
        "should see activity on closed issue assigned to user"
    );
}

#[test]
fn since_last_check_includes_comment_on_merged_mr() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "alice", "merged", false);

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, Some(10), None);
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "post-merge question",
        t,
    );

    let groups = query_since_last_check(&conn, "alice", 0).unwrap();
    let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
    assert_eq!(
        total_events, 1,
        "should see others' comments on merged MR in inbox"
    );
}

// ─── Assignment Detection Tests (Task #12) ─────────────────────────────────

#[test]
fn activity_assignment_system_note() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", true, "assigned to @alice", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].event_type, ActivityEventType::Assign);
}

#[test]
fn activity_unassignment_system_note() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", true, "unassigned @alice", t);

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].event_type, ActivityEventType::Unassign);
}

#[test]
fn activity_review_request_system_note() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
    insert_reviewer(&conn, 10, "alice");

    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, Some(10), None);
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        true,
        "requested review from @alice",
        t,
    );

    let results = query_activity(&conn, "alice", &[], 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest);
}

// ─── Since-Last-Check Mention Tests ─────────────────────────────────────────

#[test]
fn since_last_check_detects_mention_with_trailing_comma() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "please review this @alice, thanks",
        t,
    );

    let groups = query_since_last_check(&conn, "alice", 0).unwrap();
    let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
    assert_eq!(total_events, 1, "expected mention with comma to match");
}

#[test]
fn since_last_check_ignores_email_like_text() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "contact alice at foo@alice.com",
        t,
    );

    let groups = query_since_last_check(&conn, "alice", 0).unwrap();
    let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
    assert_eq!(total_events, 0, "email text should not count as mention");
}

#[test]
fn since_last_check_detects_mention_with_trailing_period() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "please review this @alice.",
        t,
    );

    let groups = query_since_last_check(&conn, "alice", 0).unwrap();
    let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
    assert_eq!(total_events, 1, "expected mention with period to match");
}

#[test]
fn since_last_check_detects_mention_inside_parentheses() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "thanks (@alice) for the update",
        t,
    );

    let groups = query_since_last_check(&conn, "alice", 0).unwrap();
    let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
    assert_eq!(total_events, 1, "expected parenthesized mention to match");
}

#[test]
fn since_last_check_ignores_domain_like_text() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "@alice.com is the old hostname",
        t,
    );

    let groups = query_since_last_check(&conn, "alice", 0).unwrap();
    let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
    assert_eq!(
        total_events, 0,
        "domain-like text should not count as mention"
    );
}

// ─── Mentioned In Tests ─────────────────────────────────────────────────────

#[test]
fn mentioned_in_finds_mention_on_unassigned_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    // alice is NOT assigned to issue 42
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "hey @alice can you look?",
        t,
    );

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].entity_type, "issue");
    assert_eq!(results[0].iid, 42);
}

#[test]
fn mentioned_in_excludes_assigned_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_assignee(&conn, 10, "alice"); // alice IS assigned
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert!(results.is_empty(), "should exclude assigned issues");
}

#[test]
fn mentioned_in_excludes_authored_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "alice"); // alice IS author
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert!(results.is_empty(), "should exclude authored issues");
}

#[test]
fn mentioned_in_finds_mention_on_non_authored_mr() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
    // alice is NOT author or reviewer
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, Some(10), None);
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "cc @alice", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].entity_type, "mr");
    assert_eq!(results[0].iid, 99);
}

#[test]
fn mentioned_in_excludes_authored_mr() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "alice", "opened", false); // alice IS author
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, Some(10), None);
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert!(results.is_empty(), "should exclude authored MRs");
}

#[test]
fn mentioned_in_excludes_reviewer_mr() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
    insert_reviewer(&conn, 10, "alice"); // alice IS reviewer
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, Some(10), None);
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "charlie", false, "@alice fyi", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert!(
        results.is_empty(),
        "should exclude MRs where user is reviewer"
    );
}

#[test]
fn mentioned_in_includes_recently_closed_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
    // Update updated_at to recent (within 7-day window)
    conn.execute(
        "UPDATE issues SET updated_at = ?1 WHERE id = 10",
        rusqlite::params![now_ms() - 2 * 24 * 3600 * 1000], // 2 days ago
    )
    .unwrap();
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert_eq!(results.len(), 1, "recently closed issue should be included");
    assert_eq!(results[0].state, "closed");
}

#[test]
fn mentioned_in_excludes_old_closed_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
    // Update updated_at to old (outside 7-day window)
    conn.execute(
        "UPDATE issues SET updated_at = ?1 WHERE id = 10",
        rusqlite::params![now_ms() - 30 * 24 * 3600 * 1000], // 30 days ago
    )
    .unwrap();
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert!(results.is_empty(), "old closed issue should be excluded");
}

#[test]
fn mentioned_in_attention_needs_attention_when_unreplied() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "@alice please review",
        t,
    );
    // alice has NOT replied

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].attention_state, AttentionState::NeedsAttention);
}

#[test]
fn mentioned_in_attention_awaiting_when_replied() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t1 = now_ms() - 5000;
    let t2 = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "@alice please review",
        t1,
    );
    insert_note_at(&conn, 201, disc_id, 1, "alice", false, "looks good", t2);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse);
}

#[test]
fn mentioned_in_project_filter() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo-a");
    insert_project(&conn, 2, "group/repo-b");
    insert_issue(&conn, 10, 1, 42, "someone");
    insert_issue(&conn, 11, 2, 43, "someone");
    let disc_a = 100;
    let disc_b = 101;
    insert_discussion(&conn, disc_a, 1, None, Some(10));
    insert_discussion(&conn, disc_b, 2, None, Some(11));
    let t = now_ms() - 1000;
    insert_note_at(&conn, 200, disc_a, 1, "bob", false, "@alice", t);
    insert_note_at(&conn, 201, disc_b, 2, "bob", false, "@alice", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[1], recency_cutoff, 0).unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].project_path, "group/repo-a");
}

#[test]
fn mentioned_in_deduplicates_multiple_mentions_same_entity() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t1 = now_ms() - 5000;
    let t2 = now_ms() - 1000;
    // Two different people mention alice on the same issue
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t1);
    insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "@alice +1", t2);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert_eq!(results.len(), 1, "should deduplicate to one entity");
}

#[test]
fn mentioned_in_rejects_false_positive_email() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    let t = now_ms() - 1000;
    insert_note_at(
        &conn,
        200,
        disc_id,
        1,
        "bob",
        false,
        "email foo@alice.com",
        t,
    );

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
    assert!(results.is_empty(), "email-like text should not match");
}

#[test]
fn mentioned_in_excludes_old_mention_on_open_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    // Mention from 45 days ago — outside 30-day mention window
    let t = now_ms() - 45 * 24 * 3600 * 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let mention_cutoff = now_ms() - 30 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, mention_cutoff).unwrap();
    assert!(
        results.is_empty(),
        "mentions older than 30 days should be excluded"
    );
}

#[test]
fn mentioned_in_includes_recent_mention_on_open_issue() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 10, 1, 42, "someone");
    let disc_id = 100;
    insert_discussion(&conn, disc_id, 1, None, Some(10));
    // Mention from 5 days ago — within 30-day window
    let t = now_ms() - 5 * 24 * 3600 * 1000;
    insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);

    let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
    let mention_cutoff = now_ms() - 30 * 24 * 3600 * 1000;
    let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, mention_cutoff).unwrap();
    assert_eq!(results.len(), 1, "recent mentions should be included");
}

// ─── Helper Tests ──────────────────────────────────────────────────────────

#[test]
fn mentioned_in_sql_materializes_core_ctes() {
    let sql = build_mentioned_in_sql("");
    assert!(
        sql.contains("candidate_issues AS MATERIALIZED"),
        "candidate_issues should be materialized"
    );
    assert!(
        sql.contains("candidate_mrs AS MATERIALIZED"),
        "candidate_mrs should be materialized"
    );
    assert!(
        sql.contains("note_ts_issue AS MATERIALIZED"),
        "note_ts_issue should be materialized"
    );
    assert!(
        sql.contains("note_ts_mr AS MATERIALIZED"),
        "note_ts_mr should be materialized"
    );
}

#[test]
fn parse_attention_state_all_variants() {
    assert_eq!(
        parse_attention_state("needs_attention"),
        AttentionState::NeedsAttention
    );
    assert_eq!(
        parse_attention_state("not_started"),
        AttentionState::NotStarted
    );
    assert_eq!(
        parse_attention_state("awaiting_response"),
        AttentionState::AwaitingResponse
    );
    assert_eq!(parse_attention_state("stale"), AttentionState::Stale);
    assert_eq!(parse_attention_state("not_ready"), AttentionState::NotReady);
    assert_eq!(parse_attention_state("unknown"), AttentionState::NotStarted);
}

#[test]
fn format_attention_reason_not_started() {
    let reason = format_attention_reason(&AttentionState::NotStarted, None, None, None);
    assert_eq!(reason, "No discussion yet");
}

#[test]
fn format_attention_reason_not_ready() {
    let reason = format_attention_reason(&AttentionState::NotReady, None, None, None);
    assert_eq!(reason, "Draft with no reviewers assigned");
}

#[test]
fn format_attention_reason_stale_with_timestamp() {
    let stale_ts = now_ms() - 35 * 24 * 3600 * 1000; // 35 days ago
    let reason = format_attention_reason(&AttentionState::Stale, None, None, Some(stale_ts));
    assert!(reason.starts_with("No activity for"), "got: {reason}");
    // 35 days = 1 month in our duration bucketing
    assert!(reason.contains("1 month"), "got: {reason}");
}

#[test]
fn format_attention_reason_needs_attention_both_timestamps() {
    let my_ts = now_ms() - 2 * 86_400_000; // 2 days ago
    let others_ts = now_ms() - 3_600_000; // 1 hour ago
    let reason = format_attention_reason(
        &AttentionState::NeedsAttention,
        Some(my_ts),
        Some(others_ts),
        Some(others_ts),
    );
    assert!(reason.contains("Others replied"), "got: {reason}");
    assert!(reason.contains("you last commented"), "got: {reason}");
}

#[test]
fn format_attention_reason_needs_attention_no_self_comment() {
    let others_ts = now_ms() - 3_600_000; // 1 hour ago
    let reason = format_attention_reason(
        &AttentionState::NeedsAttention,
        None,
        Some(others_ts),
        Some(others_ts),
    );
    assert!(reason.contains("Others commented"), "got: {reason}");
    assert!(reason.contains("you haven't replied"), "got: {reason}");
}

#[test]
fn format_attention_reason_awaiting_response() {
    let my_ts = now_ms() - 7_200_000; // 2 hours ago
    let reason = format_attention_reason(
        &AttentionState::AwaitingResponse,
        Some(my_ts),
        None,
        Some(my_ts),
    );
    assert!(reason.contains("You replied"), "got: {reason}");
    assert!(reason.contains("awaiting others"), "got: {reason}");
}

#[test]
fn parse_event_type_all_variants() {
    assert_eq!(parse_event_type("note"), ActivityEventType::Note);
    assert_eq!(parse_event_type("mention_note"), ActivityEventType::Note);
    assert_eq!(
        parse_event_type("status_change"),
        ActivityEventType::StatusChange
    );
    assert_eq!(
        parse_event_type("label_change"),
        ActivityEventType::LabelChange
    );
    assert_eq!(parse_event_type("assign"), ActivityEventType::Assign);
    assert_eq!(parse_event_type("unassign"), ActivityEventType::Unassign);
    assert_eq!(
        parse_event_type("review_request"),
        ActivityEventType::ReviewRequest
    );
    assert_eq!(
        parse_event_type("milestone_change"),
        ActivityEventType::MilestoneChange
    );
    assert_eq!(parse_event_type("unknown"), ActivityEventType::Note);
}

#[test]
fn build_project_clause_empty() {
    assert_eq!(build_project_clause("i.project_id", &[]), "");
}

#[test]
fn build_project_clause_single() {
    let clause = build_project_clause("i.project_id", &[1]);
    assert_eq!(clause, "AND i.project_id = ?2");
}

#[test]
fn build_project_clause_multiple() {
    let clause = build_project_clause("i.project_id", &[1, 2, 3]);
    assert_eq!(clause, "AND i.project_id IN (?2,?3,?4)");
}

#[test]
fn build_project_clause_at_custom_start() {
    let clause = build_project_clause_at("p.id", &[1, 2], 3);
    assert_eq!(clause, "AND p.id IN (?3,?4)");
}
