use super::*;
use crate::test_support::{insert_project, setup_test_db};

fn default_scoring() -> ScoringConfig {
    ScoringConfig::default()
}

/// as_of_ms value for tests: 1 second after now, giving near-zero decay.
fn test_as_of_ms() -> i64 {
    now_ms() + 1000
}

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

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

fn insert_issue_with_state(
    conn: &Connection,
    id: i64,
    project_id: i64,
    iid: i64,
    author: &str,
    state: &str,
) {
    conn.execute(
        "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
        rusqlite::params![
            id,
            id * 10,
            project_id,
            iid,
            format!("Issue {iid}"),
            state,
            author,
            now_ms(),
            now_ms(),
            now_ms()
        ],
    )
    .unwrap();
}

fn insert_discussion(
    conn: &Connection,
    id: i64,
    project_id: i64,
    mr_id: Option<i64>,
    issue_id: Option<i64>,
    resolvable: bool,
    resolved: bool,
) {
    let noteable_type = if mr_id.is_some() {
        "MergeRequest"
    } else {
        "Issue"
    };
    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, ?7, ?8, ?9, ?10)",
        rusqlite::params![
            id,
            format!("disc-{id}"),
            project_id,
            mr_id,
            issue_id,
            noteable_type,
            i32::from(resolvable),
            i32::from(resolved),
            now_ms(),
            now_ms()
        ],
    )
    .unwrap();
}

#[allow(clippy::too_many_arguments)]
fn insert_diffnote(
    conn: &Connection,
    id: i64,
    discussion_id: i64,
    project_id: i64,
    author: &str,
    file_path: &str,
    body: &str,
) {
    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, position_new_path)
         VALUES (?1, ?2, ?3, ?4, 'DiffNote', 0, ?5, ?6, ?7, ?8, ?9, ?10)",
        rusqlite::params![
            id,
            id * 10,
            discussion_id,
            project_id,
            author,
            body,
            now_ms(),
            now_ms(),
            now_ms(),
            file_path
        ],
    )
    .unwrap();
}

fn insert_note(conn: &Connection, id: i64, discussion_id: i64, project_id: i64, author: &str) {
    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', 0, ?5, 'comment', ?6, ?7, ?8)",
        rusqlite::params![
            id,
            id * 10,
            discussion_id,
            project_id,
            author,
            now_ms(),
            now_ms(),
            now_ms()
        ],
    )
    .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();
}

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_file_change(
    conn: &Connection,
    mr_id: i64,
    project_id: i64,
    new_path: &str,
    change_type: &str,
) {
    conn.execute(
        "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type)
         VALUES (?1, ?2, ?3, ?4)",
        rusqlite::params![mr_id, project_id, new_path, change_type],
    )
    .unwrap();
}

#[allow(clippy::too_many_arguments, dead_code)]
fn insert_mr_at(
    conn: &Connection,
    id: i64,
    project_id: i64,
    iid: i64,
    author: &str,
    state: &str,
    updated_at_ms: i64,
    merged_at_ms: Option<i64>,
    closed_at_ms: Option<i64>,
) {
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, merged_at, closed_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
        rusqlite::params![
            id,
            id * 10,
            project_id,
            iid,
            format!("MR {iid}"),
            author,
            state,
            now_ms(),
            updated_at_ms,
            merged_at_ms,
            closed_at_ms
        ],
    )
    .unwrap();
}

#[allow(clippy::too_many_arguments, dead_code)]
fn insert_diffnote_at(
    conn: &Connection,
    id: i64,
    discussion_id: i64,
    project_id: i64,
    author: &str,
    new_path: &str,
    old_path: Option<&str>,
    body: &str,
    created_at_ms: i64,
) {
    conn.execute(
        "INSERT INTO notes (id, gitlab_id, project_id, discussion_id, author_username, note_type, is_system, position_new_path, position_old_path, body, created_at, updated_at, last_seen_at)
         VALUES (?1, ?2, ?3, ?4, ?5, 'DiffNote', 0, ?6, ?7, ?8, ?9, ?9, ?9)",
        rusqlite::params![
            id,
            id * 10,
            project_id,
            discussion_id,
            author,
            new_path,
            old_path,
            body,
            created_at_ms
        ],
    )
    .unwrap();
}

#[allow(dead_code)]
fn insert_file_change_with_old_path(
    conn: &Connection,
    mr_id: i64,
    project_id: i64,
    new_path: &str,
    old_path: Option<&str>,
    change_type: &str,
) {
    conn.execute(
        "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, old_path, change_type)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        rusqlite::params![mr_id, project_id, new_path, old_path, change_type],
    )
    .unwrap();
}

#[test]
fn test_is_file_path_discrimination() {
    // Contains '/' -> file path
    assert!(matches!(
        resolve_mode(&WhoArgs {
            target: Some("src/auth/".to_string()),
            path: None,
            active: false,
            overlap: None,
            reviews: false,
            since: None,
            project: None,
            limit: None,
            detail: false,
            no_detail: false,
            fields: None,
            as_of: None,
            explain_score: false,
            include_bots: false,
            include_closed: false,
            all_history: false,
        })
        .unwrap(),
        WhoMode::Expert { .. }
    ));

    // No '/' -> username
    assert!(matches!(
        resolve_mode(&WhoArgs {
            target: Some("asmith".to_string()),
            path: None,
            active: false,
            overlap: None,
            reviews: false,
            since: None,
            project: None,
            limit: None,
            detail: false,
            no_detail: false,
            fields: None,
            as_of: None,
            explain_score: false,
            include_bots: false,
            include_closed: false,
            all_history: false,
        })
        .unwrap(),
        WhoMode::Workload { .. }
    ));

    // With @ prefix -> username (stripped)
    assert!(matches!(
        resolve_mode(&WhoArgs {
            target: Some("@asmith".to_string()),
            path: None,
            active: false,
            overlap: None,
            reviews: false,
            since: None,
            project: None,
            limit: None,
            detail: false,
            no_detail: false,
            fields: None,
            as_of: None,
            explain_score: false,
            include_bots: false,
            include_closed: false,
            all_history: false,
        })
        .unwrap(),
        WhoMode::Workload { .. }
    ));

    // --reviews flag -> reviews mode
    assert!(matches!(
        resolve_mode(&WhoArgs {
            target: Some("asmith".to_string()),
            path: None,
            active: false,
            overlap: None,
            reviews: true,
            since: None,
            project: None,
            limit: None,
            detail: false,
            no_detail: false,
            fields: None,
            as_of: None,
            explain_score: false,
            include_bots: false,
            include_closed: false,
            all_history: false,
        })
        .unwrap(),
        WhoMode::Reviews { .. }
    ));

    // --path flag -> expert mode (handles root files)
    assert!(matches!(
        resolve_mode(&WhoArgs {
            target: None,
            path: Some("README.md".to_string()),
            active: false,
            overlap: None,
            reviews: false,
            since: None,
            project: None,
            limit: None,
            detail: false,
            no_detail: false,
            fields: None,
            as_of: None,
            explain_score: false,
            include_bots: false,
            include_closed: false,
            all_history: false,
        })
        .unwrap(),
        WhoMode::Expert { .. }
    ));

    // --path flag with dotless file -> expert mode
    assert!(matches!(
        resolve_mode(&WhoArgs {
            target: None,
            path: Some("Makefile".to_string()),
            active: false,
            overlap: None,
            reviews: false,
            since: None,
            project: None,
            limit: None,
            detail: false,
            no_detail: false,
            fields: None,
            as_of: None,
            explain_score: false,
            include_bots: false,
            include_closed: false,
            all_history: false,
        })
        .unwrap(),
        WhoMode::Expert { .. }
    ));
}

#[test]
fn test_detail_rejected_outside_expert_mode() {
    let args = WhoArgs {
        target: Some("asmith".to_string()),
        path: None,
        active: false,
        overlap: None,
        reviews: false,
        since: None,
        project: None,
        limit: None,
        detail: true,
        no_detail: false,
        fields: None,
        as_of: None,
        explain_score: false,
        include_bots: false,
        include_closed: false,
        all_history: false,
    };
    let mode = resolve_mode(&args).unwrap();
    let err = validate_mode_flags(&mode, &args).unwrap_err();
    let msg = err.to_string();
    assert!(
        msg.contains("--detail is only supported in expert mode"),
        "unexpected error: {msg}"
    );
}

#[test]
fn test_detail_allowed_in_expert_mode() {
    let args = WhoArgs {
        target: None,
        path: Some("README.md".to_string()),
        active: false,
        overlap: None,
        reviews: false,
        since: None,
        project: None,
        limit: None,
        detail: true,
        no_detail: false,
        fields: None,
        as_of: None,
        explain_score: false,
        include_bots: false,
        include_closed: false,
        all_history: false,
    };
    let mode = resolve_mode(&args).unwrap();
    assert!(validate_mode_flags(&mode, &args).is_ok());
}

#[test]
fn test_build_path_query() {
    let conn = setup_test_db();

    // Directory with trailing slash -> prefix
    let pq = build_path_query(&conn, "src/auth/", None).unwrap();
    assert_eq!(pq.value, "src/auth/%");
    assert!(pq.is_prefix);

    // Directory without trailing slash (no dot in last segment) -> prefix
    let pq = build_path_query(&conn, "src/auth", None).unwrap();
    assert_eq!(pq.value, "src/auth/%");
    assert!(pq.is_prefix);

    // File with extension -> exact
    let pq = build_path_query(&conn, "src/auth/login.rs", None).unwrap();
    assert_eq!(pq.value, "src/auth/login.rs");
    assert!(!pq.is_prefix);

    // Root file -> exact
    let pq = build_path_query(&conn, "README.md", None).unwrap();
    assert_eq!(pq.value, "README.md");
    assert!(!pq.is_prefix);

    // Directory with dots in non-leaf segment -> prefix
    let pq = build_path_query(&conn, ".github/workflows/", None).unwrap();
    assert_eq!(pq.value, ".github/workflows/%");
    assert!(pq.is_prefix);

    // Versioned directory path -> prefix
    let pq = build_path_query(&conn, "src/v1.2/auth/", None).unwrap();
    assert_eq!(pq.value, "src/v1.2/auth/%");
    assert!(pq.is_prefix);

    // Path with LIKE metacharacters -> prefix, escaped
    let pq = build_path_query(&conn, "src/test_files/", None).unwrap();
    assert_eq!(pq.value, "src/test\\_files/%");
    assert!(pq.is_prefix);

    // Dotless root file -> exact match (root path without '/')
    let pq = build_path_query(&conn, "Makefile", None).unwrap();
    assert_eq!(pq.value, "Makefile");
    assert!(!pq.is_prefix);

    let pq = build_path_query(&conn, "LICENSE", None).unwrap();
    assert_eq!(pq.value, "LICENSE");
    assert!(!pq.is_prefix);

    // Dotless root path with trailing '/' -> directory prefix (explicit override)
    let pq = build_path_query(&conn, "Makefile/", None).unwrap();
    assert_eq!(pq.value, "Makefile/%");
    assert!(pq.is_prefix);
}

#[test]
fn test_escape_like() {
    assert_eq!(escape_like("normal/path"), "normal/path");
    assert_eq!(escape_like("has_underscore"), "has\\_underscore");
    assert_eq!(escape_like("has%percent"), "has\\%percent");
    assert_eq!(escape_like("has\\backslash"), "has\\\\backslash");
}

#[test]
fn test_build_path_query_exact_does_not_escape() {
    let conn = setup_test_db();
    // '_' must NOT be escaped for exact match (=).
    let pq = build_path_query(&conn, "README_with_underscore.md", None).unwrap();
    assert_eq!(pq.value, "README_with_underscore.md");
    assert!(!pq.is_prefix);
}

#[test]
fn test_path_flag_dotless_root_file_is_exact() {
    let conn = setup_test_db();
    // --path Makefile must produce an exact match, not Makefile/%
    let pq = build_path_query(&conn, "Makefile", None).unwrap();
    assert_eq!(pq.value, "Makefile");
    assert!(!pq.is_prefix);

    let pq = build_path_query(&conn, "Dockerfile", None).unwrap();
    assert_eq!(pq.value, "Dockerfile");
    assert!(!pq.is_prefix);
}

#[test]
fn test_expert_query() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "reviewer_b",
        "src/auth/login.rs",
        "**suggestion**: use const",
    );
    insert_diffnote(
        &conn,
        2,
        1,
        1,
        "reviewer_b",
        "src/auth/login.rs",
        "**question**: why?",
    );
    insert_diffnote(
        &conn,
        3,
        1,
        1,
        "reviewer_c",
        "src/auth/session.rs",
        "looks good",
    );

    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    assert_eq!(result.experts.len(), 3); // author_a, reviewer_b, reviewer_c
    assert_eq!(result.experts[0].username, "author_a"); // highest score (authorship dominates)
}

#[test]
fn test_workload_query() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_issue(&conn, 1, 1, 42, "someone_else");
    insert_assignee(&conn, 1, "dev_a");
    insert_mr(&conn, 1, 1, 100, "dev_a", "opened");

    let result = query_workload(&conn, "dev_a", None, None, 20, true).unwrap();
    assert_eq!(result.assigned_issues.len(), 1);
    assert_eq!(result.authored_mrs.len(), 1);
}

#[test]
fn test_reviews_query() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "reviewer_b",
        "src/foo.rs",
        "**suggestion**: refactor",
    );
    insert_diffnote(
        &conn,
        2,
        1,
        1,
        "reviewer_b",
        "src/bar.rs",
        "**question**: why?",
    );
    insert_diffnote(&conn, 3, 1, 1, "reviewer_b", "src/baz.rs", "looks good");

    let result = query_reviews(&conn, "reviewer_b", None, 0).unwrap();
    assert_eq!(result.total_diffnotes, 3);
    assert_eq!(result.categorized_count, 2);
    assert_eq!(result.categories.len(), 2);
}

#[test]
fn test_active_query() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "opened");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/foo.rs", "needs work");
    // Second note by same participant -- note_count should be 2, participants still ["reviewer_b"]
    insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/foo.rs", "follow-up");

    let result = query_active(&conn, None, 0, 20, true).unwrap();
    assert_eq!(result.total_unresolved_in_window, 1);
    assert_eq!(result.discussions.len(), 1);
    assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]);
    // This was a regression in iteration 4: note_count was counting participants, not notes
    assert_eq!(result.discussions[0].note_count, 2);
    assert!(result.discussions[0].discussion_id > 0);
}

#[test]
fn test_overlap_dual_roles() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    // User is both author of one MR and reviewer of another at same path
    insert_mr(&conn, 1, 1, 100, "dual_user", "opened");
    insert_mr(&conn, 2, 1, 200, "other_author", "opened");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_discussion(&conn, 2, 1, Some(2), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "someone",
        "src/auth/login.rs",
        "review of dual_user's MR",
    );
    insert_diffnote(
        &conn,
        2,
        2,
        1,
        "dual_user",
        "src/auth/login.rs",
        "dual_user reviewing other MR",
    );

    let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
    let dual = result
        .users
        .iter()
        .find(|u| u.username == "dual_user")
        .unwrap();
    assert!(dual.author_touch_count > 0);
    assert!(dual.review_touch_count > 0);
    assert_eq!(format_overlap_role(dual), "A+R");
    // MR refs should be project-qualified
    assert!(dual.mr_refs.iter().any(|r| r.contains("team/backend!")));
}

#[test]
fn test_overlap_multi_project_mr_refs() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_project(&conn, 2, "team/frontend");
    insert_mr(&conn, 1, 1, 100, "author_a", "opened");
    insert_mr(&conn, 2, 2, 100, "author_a", "opened"); // Same iid, different project
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_discussion(&conn, 2, 2, Some(2), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review");
    insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review");

    let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
    let reviewer = result
        .users
        .iter()
        .find(|u| u.username == "reviewer_x")
        .unwrap();
    // Should have two distinct refs despite same iid
    assert!(reviewer.mr_refs.contains(&"team/backend!100".to_string()));
    assert!(reviewer.mr_refs.contains(&"team/frontend!100".to_string()));
}

#[test]
fn test_normalize_review_prefix() {
    assert_eq!(normalize_review_prefix("suggestion"), "suggestion");
    assert_eq!(normalize_review_prefix("Suggestion:"), "suggestion");
    assert_eq!(
        normalize_review_prefix("suggestion (non-blocking):"),
        "suggestion"
    );
    assert_eq!(normalize_review_prefix("Nitpick:"), "nit");
    assert_eq!(normalize_review_prefix("nit (non-blocking):"), "nit");
    assert_eq!(normalize_review_prefix("question"), "question");
    assert_eq!(normalize_review_prefix("TODO:"), "todo");
}

#[test]
fn test_normalize_repo_path() {
    // Strips leading ./
    assert_eq!(normalize_repo_path("./src/foo/"), "src/foo/");
    // Strips leading /
    assert_eq!(normalize_repo_path("/src/foo/"), "src/foo/");
    // Strips leading ./ recursively
    assert_eq!(normalize_repo_path("././src/foo"), "src/foo");
    // Converts Windows backslashes when no forward slashes
    assert_eq!(normalize_repo_path("src\\foo\\bar.rs"), "src/foo/bar.rs");
    // Does NOT convert backslashes when forward slashes present
    assert_eq!(normalize_repo_path("src/foo\\bar"), "src/foo\\bar");
    // Collapses repeated //
    assert_eq!(normalize_repo_path("src//foo//bar/"), "src/foo/bar/");
    // Trims whitespace
    assert_eq!(normalize_repo_path("  src/foo/  "), "src/foo/");
    // Identity for clean paths
    assert_eq!(normalize_repo_path("src/foo/bar.rs"), "src/foo/bar.rs");
}

#[test]
fn test_lookup_project_path() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    assert_eq!(lookup_project_path(&conn, 1).unwrap(), "team/backend");
}

#[test]
fn test_build_path_query_dotless_subdir_file_uses_db_probe() {
    // Dotless file in subdirectory (src/Dockerfile) would normally be
    // treated as a directory. The DB probe detects it's actually a file.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "opened");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/Dockerfile", "note");

    let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
    assert_eq!(pq.value, "src/Dockerfile");
    assert!(!pq.is_prefix);

    // Same path without DB data -> falls through to prefix
    let conn2 = setup_test_db();
    let pq2 = build_path_query(&conn2, "src/Dockerfile", None).unwrap();
    assert_eq!(pq2.value, "src/Dockerfile/%");
    assert!(pq2.is_prefix);
}

#[test]
fn test_build_path_query_probe_is_project_scoped() {
    // Path exists as a dotless file in project 1; project 2 should not
    // treat it as an exact file unless it exists there too.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/a");
    insert_project(&conn, 2, "team/b");
    insert_mr(&conn, 1, 1, 10, "author_a", "opened");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "rev", "infra/Makefile", "note");

    // Unscoped: finds exact match in project 1 -> exact
    let pq_unscoped = build_path_query(&conn, "infra/Makefile", None).unwrap();
    assert!(!pq_unscoped.is_prefix);

    // Scoped to project 2: no data -> falls back to prefix
    let pq_scoped = build_path_query(&conn, "infra/Makefile", Some(2)).unwrap();
    assert!(pq_scoped.is_prefix);

    // Scoped to project 1: finds data -> exact
    let pq_scoped1 = build_path_query(&conn, "infra/Makefile", Some(1)).unwrap();
    assert!(!pq_scoped1.is_prefix);
}

#[test]
fn test_expert_excludes_self_review_notes() {
    // MR author commenting on their own diff should not be counted as reviewer
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "opened");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    // author_a comments on their own MR diff (clarification)
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "author_a",
        "src/auth/login.rs",
        "clarification",
    );
    // reviewer_b also reviews
    insert_diffnote(
        &conn,
        2,
        1,
        1,
        "reviewer_b",
        "src/auth/login.rs",
        "looks good",
    );

    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    // author_a should appear as author only, not as reviewer
    let author = result
        .experts
        .iter()
        .find(|e| e.username == "author_a")
        .unwrap();
    assert_eq!(author.review_mr_count, 0);
    assert!(author.author_mr_count > 0);

    // reviewer_b should be a reviewer
    let reviewer = result
        .experts
        .iter()
        .find(|e| e.username == "reviewer_b")
        .unwrap();
    assert!(reviewer.review_mr_count > 0);
}

#[test]
fn test_overlap_excludes_self_review_notes() {
    // MR author commenting on their own diff should not inflate reviewer counts
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "opened");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    // author_a comments on their own MR diff (clarification)
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "author_a",
        "src/auth/login.rs",
        "clarification",
    );

    let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
    let u = result.users.iter().find(|u| u.username == "author_a");
    // Should NOT be credited as reviewer touch
    assert!(u.map_or(0, |x| x.review_touch_count) == 0);
}

#[test]
fn test_active_participants_sorted() {
    // Participants should be sorted alphabetically for deterministic output
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "opened");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "zebra_user", "src/foo.rs", "note 1");
    insert_diffnote(&conn, 2, 1, 1, "alpha_user", "src/foo.rs", "note 2");

    let result = query_active(&conn, None, 0, 20, true).unwrap();
    assert_eq!(
        result.discussions[0].participants,
        vec!["alpha_user", "zebra_user"]
    );
}

#[test]
fn test_expert_truncation() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    // Create 3 experts
    for i in 1..=3 {
        insert_mr(&conn, i, 1, 100 + i, &format!("author_{i}"), "opened");
        insert_discussion(&conn, i, 1, Some(i), None, true, false);
        insert_diffnote(
            &conn,
            i,
            i,
            1,
            &format!("reviewer_{i}"),
            "src/auth/login.rs",
            "note",
        );
    }

    // limit = 2, should return truncated = true
    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        2,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    assert!(result.truncated);
    assert_eq!(result.experts.len(), 2);

    // limit = 10, should return truncated = false
    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        10,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    assert!(!result.truncated);
}

#[test]
fn test_expert_file_changes_only() {
    // MR author should appear even when there are zero DiffNotes
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "file_author", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");

    let result = query_expert(
        &conn,
        "src/auth/login.rs",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    assert_eq!(result.experts.len(), 1);
    assert_eq!(result.experts[0].username, "file_author");
    assert!(result.experts[0].author_mr_count > 0);
    assert_eq!(result.experts[0].review_mr_count, 0);
}

#[test]
fn test_expert_mr_reviewer_via_file_changes() {
    // A reviewer assigned via mr_reviewers should appear when that MR
    // touched the queried file (via mr_file_changes)
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
    insert_reviewer(&conn, 1, "assigned_reviewer");

    let result = query_expert(
        &conn,
        "src/auth/login.rs",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    let reviewer = result
        .experts
        .iter()
        .find(|e| e.username == "assigned_reviewer");
    assert!(reviewer.is_some(), "assigned_reviewer should appear");
    assert!(reviewer.unwrap().review_mr_count > 0);
}

#[test]
fn test_expert_deduplicates_across_signals() {
    // User who is BOTH a DiffNote reviewer AND an mr_reviewers entry for
    // the same MR should be counted only once per MR
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "reviewer_b",
        "src/auth/login.rs",
        "looks good",
    );
    // Same user also listed as assigned reviewer, with file change data
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
    insert_reviewer(&conn, 1, "reviewer_b");

    let result = query_expert(
        &conn,
        "src/auth/login.rs",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    let reviewer = result
        .experts
        .iter()
        .find(|e| e.username == "reviewer_b")
        .unwrap();
    // Should be 1 MR, not 2 (dedup across DiffNote + mr_reviewers)
    assert_eq!(reviewer.review_mr_count, 1);
}

#[test]
fn test_expert_combined_diffnote_and_file_changes() {
    // Author with DiffNotes on path A and file_changes on path B should
    // get credit for both when queried with a directory prefix
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    // MR 1: has DiffNotes on login.rs
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note");
    // MR 2: has file_changes on session.rs (no DiffNotes)
    insert_mr(&conn, 2, 1, 200, "author_a", "merged");
    insert_file_change(&conn, 2, 1, "src/auth/session.rs", "added");

    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    let author = result
        .experts
        .iter()
        .find(|e| e.username == "author_a")
        .unwrap();
    // Should count 2 authored MRs (one from DiffNote path, one from file changes)
    assert_eq!(author.author_mr_count, 2);
}

#[test]
fn test_expert_file_changes_prefix_match() {
    // Directory prefix queries should pick up mr_file_changes under the directory
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
    insert_file_change(&conn, 1, 1, "src/auth/session.rs", "added");

    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    assert_eq!(result.path_match, "prefix");
    assert_eq!(result.experts.len(), 1);
    assert_eq!(result.experts[0].username, "author_a");
}

#[test]
fn test_overlap_file_changes_only() {
    // Overlap mode should also find users via mr_file_changes
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
    insert_reviewer(&conn, 1, "reviewer_x");

    let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
    assert!(
        result.users.iter().any(|u| u.username == "author_a"),
        "author_a should appear via file_changes"
    );
    assert!(
        result.users.iter().any(|u| u.username == "reviewer_x"),
        "reviewer_x should appear via mr_reviewers + file_changes"
    );
}

#[test]
fn test_build_path_query_resolves_via_file_changes() {
    // DB probe should detect exact file match from mr_file_changes even
    // when no DiffNotes exist for the path
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/Dockerfile", "modified");

    let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
    assert_eq!(pq.value, "src/Dockerfile");
    assert!(!pq.is_prefix);
}

#[test]
fn test_expert_excludes_self_assigned_reviewer() {
    // MR author listed in mr_reviewers for their own MR should NOT be
    // counted as a reviewer (same principle as DiffNote self-review exclusion)
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
    // author_a is self-assigned as reviewer
    insert_reviewer(&conn, 1, "author_a");
    // real_reviewer is also assigned
    insert_reviewer(&conn, 1, "real_reviewer");

    let result = query_expert(
        &conn,
        "src/auth/login.rs",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();

    // author_a should appear as author only, not reviewer
    let author = result
        .experts
        .iter()
        .find(|e| e.username == "author_a")
        .unwrap();
    assert_eq!(author.review_mr_count, 0);
    assert!(author.author_mr_count > 0);

    // real_reviewer should appear as reviewer
    let reviewer = result
        .experts
        .iter()
        .find(|e| e.username == "real_reviewer")
        .unwrap();
    assert!(reviewer.review_mr_count > 0);
}

#[test]
fn test_overlap_excludes_self_assigned_reviewer() {
    // Same self-review exclusion for overlap mode via file changes
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
    insert_reviewer(&conn, 1, "author_a"); // self-assigned

    let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
    let user = result.users.iter().find(|u| u.username == "author_a");
    // Should appear (as author) but NOT have reviewer touch count
    assert!(user.is_some());
    assert_eq!(user.unwrap().review_touch_count, 0);
}

// ─── Suffix / Fuzzy Path Resolution Tests ───────────────────────────────

#[test]
fn test_build_path_query_suffix_resolves_bare_filename() {
    // User types just "login.rs" but the DB has "src/auth/login.rs"
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");

    let pq = build_path_query(&conn, "login.rs", None).unwrap();
    assert_eq!(pq.value, "src/auth/login.rs");
    assert!(!pq.is_prefix);
}

#[test]
fn test_build_path_query_suffix_resolves_partial_path() {
    // User types "auth/login.rs" but full path is "src/auth/login.rs"
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");

    let pq = build_path_query(&conn, "auth/login.rs", None).unwrap();
    assert_eq!(pq.value, "src/auth/login.rs");
    assert!(!pq.is_prefix);
}

#[test]
fn test_build_path_query_suffix_ambiguous_returns_error() {
    // Two different files share the same filename -> Ambiguous error
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/utils.rs", "modified");
    insert_file_change(&conn, 1, 1, "src/db/utils.rs", "modified");

    let err = build_path_query(&conn, "utils.rs", None).unwrap_err();
    let msg = err.to_string();
    assert!(
        msg.contains("src/auth/utils.rs"),
        "should list candidates: {msg}"
    );
    assert!(
        msg.contains("src/db/utils.rs"),
        "should list candidates: {msg}"
    );
}

#[test]
fn test_build_path_query_suffix_scoped_to_project() {
    // Two projects have the same filename; scoping to one should resolve
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_project(&conn, 2, "team/frontend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_mr(&conn, 2, 2, 200, "author_b", "merged");
    insert_file_change(&conn, 1, 1, "src/utils.rs", "modified");
    insert_file_change(&conn, 2, 2, "lib/utils.rs", "modified");

    // Unscoped -> ambiguous
    assert!(build_path_query(&conn, "utils.rs", None).is_err());

    // Scoped to project 1 -> resolves
    let pq = build_path_query(&conn, "utils.rs", Some(1)).unwrap();
    assert_eq!(pq.value, "src/utils.rs");
    assert!(!pq.is_prefix);
}

#[test]
fn test_build_path_query_suffix_deduplicates_across_sources() {
    // Same path in both notes AND mr_file_changes -> single unique match, not ambiguous
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "reviewer_a",
        "src/auth/login.rs",
        "review note",
    );

    let pq = build_path_query(&conn, "login.rs", None).unwrap();
    assert_eq!(pq.value, "src/auth/login.rs");
    assert!(!pq.is_prefix);
}

#[test]
fn test_build_path_query_exact_match_still_preferred() {
    // If the exact path exists in the DB, suffix should NOT be attempted
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "author_a", "merged");
    insert_file_change(&conn, 1, 1, "README.md", "modified");
    insert_file_change(&conn, 1, 1, "docs/README.md", "modified");

    // "README.md" exists as exact match -> use it directly, no ambiguity
    let pq = build_path_query(&conn, "README.md", None).unwrap();
    assert_eq!(pq.value, "README.md");
    assert!(!pq.is_prefix);
}

#[test]
fn test_expert_scoring_weights_are_configurable() {
    // With reviewer-heavy weights, reviewer should rank above author.
    // With author-heavy weights (default), author should rank above reviewer.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "the_author", "merged");
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "the_reviewer",
        "src/app.rs",
        "lgtm -- a substantive review comment",
    );
    insert_reviewer(&conn, 1, "the_reviewer");

    // Default weights: author=25, reviewer=10 → author wins
    let result = query_expert(
        &conn,
        "src/app.rs",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    assert_eq!(result.experts[0].username, "the_author");

    // Custom weights: flip so reviewer dominates
    let flipped = ScoringConfig {
        author_weight: 5,
        reviewer_weight: 30,
        note_bonus: 1,
        ..Default::default()
    };
    let result = query_expert(
        &conn,
        "src/app.rs",
        None,
        0,
        test_as_of_ms(),
        20,
        &flipped,
        false,
        false,
        false,
    )
    .unwrap();
    assert_eq!(result.experts[0].username, "the_reviewer");
}

#[test]
fn test_expert_mr_refs() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 891, "author_a", "merged");
    insert_mr(&conn, 2, 1, 847, "author_a", "merged");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_discussion(&conn, 2, 1, Some(2), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1");
    insert_diffnote(&conn, 2, 2, 1, "reviewer_b", "src/auth/login.rs", "note2");

    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();

    // reviewer_b should have MR refs
    let reviewer = result
        .experts
        .iter()
        .find(|e| e.username == "reviewer_b")
        .unwrap();
    assert!(reviewer.mr_refs.contains(&"team/backend!847".to_string()));
    assert!(reviewer.mr_refs.contains(&"team/backend!891".to_string()));
    assert_eq!(reviewer.mr_refs_total, 2);
    assert!(!reviewer.mr_refs_truncated);

    // author_a should also have MR refs
    let author = result
        .experts
        .iter()
        .find(|e| e.username == "author_a")
        .unwrap();
    assert!(author.mr_refs.contains(&"team/backend!847".to_string()));
    assert!(author.mr_refs.contains(&"team/backend!891".to_string()));
    assert_eq!(author.mr_refs_total, 2);
}

#[test]
fn test_expert_mr_refs_multi_project() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_project(&conn, 2, "team/frontend");
    insert_mr(&conn, 1, 1, 100, "author_a", "opened");
    insert_mr(&conn, 2, 2, 100, "author_a", "opened"); // Same iid, different project
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_discussion(&conn, 2, 2, Some(2), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review");
    insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review");

    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    let reviewer = result
        .experts
        .iter()
        .find(|e| e.username == "reviewer_x")
        .unwrap();
    // Should have two distinct refs despite same iid
    assert!(reviewer.mr_refs.contains(&"team/backend!100".to_string()));
    assert!(reviewer.mr_refs.contains(&"team/frontend!100".to_string()));
    assert_eq!(reviewer.mr_refs_total, 2);
}

#[test]
fn test_half_life_decay_math() {
    // elapsed=0 -> 1.0 (no decay)
    assert!((half_life_decay(0, 180) - 1.0).abs() < f64::EPSILON);

    // elapsed=half_life -> 0.5
    let half_life_ms = 180 * 86_400_000_i64;
    assert!((half_life_decay(half_life_ms, 180) - 0.5).abs() < 1e-10);

    // elapsed=2*half_life -> 0.25
    assert!((half_life_decay(2 * half_life_ms, 180) - 0.25).abs() < 1e-10);

    // half_life_days=0 -> 0.0 (guard against div-by-zero)
    assert!((half_life_decay(1_000_000, 0)).abs() < f64::EPSILON);

    // negative elapsed clamped to 0 -> 1.0
    assert!((half_life_decay(-5_000_000, 180) - 1.0).abs() < f64::EPSILON);
}

#[test]
fn test_path_normalization_handles_dot_and_double_slash() {
    assert_eq!(normalize_repo_path("./src//foo.rs"), "src/foo.rs");
    assert_eq!(normalize_repo_path("  src/bar.rs  "), "src/bar.rs");
    assert_eq!(normalize_repo_path("src/foo.rs"), "src/foo.rs");
    assert_eq!(normalize_repo_path(""), "");
}

#[test]
fn test_path_normalization_preserves_prefix_semantics() {
    // Trailing slash preserved for prefix intent
    assert_eq!(normalize_repo_path("./src/dir/"), "src/dir/");
    // No trailing slash = file, not prefix
    assert_eq!(normalize_repo_path("src/dir"), "src/dir");
}

#[test]
fn test_expert_sql_v2_prepares_exact() {
    let conn = setup_test_db();
    let sql = build_expert_sql_v2(false);
    // Verify the SQL is syntactically valid and INDEXED BY references exist
    conn.prepare_cached(&sql)
        .expect("v2 exact SQL should parse");
}

#[test]
fn test_expert_sql_v2_prepares_prefix() {
    let conn = setup_test_db();
    let sql = build_expert_sql_v2(true);
    conn.prepare_cached(&sql)
        .expect("v2 prefix SQL should parse");
}

#[test]
fn test_expert_sql_v2_returns_signals() {
    let conn = setup_test_db();
    let now = now_ms();
    insert_project(&conn, 1, "team/backend");
    insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None);
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "reviewer_b",
        "src/auth/login.rs",
        "substantive review comment here",
    );
    insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
    insert_reviewer(&conn, 1, "reviewer_b");

    let sql = build_expert_sql_v2(false);
    let mut stmt = conn.prepare_cached(&sql).unwrap();
    let rows: Vec<(String, String, i64, i64, i64, f64)> = stmt
        .query_map(
            rusqlite::params![
                "src/auth/login.rs", // ?1 path
                0_i64,               // ?2 since_ms
                Option::<i64>::None, // ?3 project_id
                now + 1000,          // ?4 as_of_ms (slightly in future to include test data)
                0.5_f64,             // ?5 closed_mr_multiplier
                20_i64,              // ?6 reviewer_min_note_chars
            ],
            |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, i64>(2)?,
                    row.get::<_, i64>(3)?,
                    row.get::<_, i64>(4)?,
                    row.get::<_, f64>(5)?,
                ))
            },
        )
        .unwrap()
        .collect::<std::result::Result<Vec<_>, _>>()
        .unwrap();

    // Should have signals for both author_a and reviewer_b
    let usernames: Vec<&str> = rows.iter().map(|r| r.0.as_str()).collect();
    assert!(usernames.contains(&"author_a"), "should contain author_a");
    assert!(
        usernames.contains(&"reviewer_b"),
        "should contain reviewer_b"
    );

    // Verify signal types are from the expected set
    let valid_signals = [
        "diffnote_author",
        "file_author",
        "file_reviewer_participated",
        "file_reviewer_assigned",
        "note_group",
    ];
    for row in &rows {
        assert!(
            valid_signals.contains(&row.1.as_str()),
            "unexpected signal type: {}",
            row.1
        );
    }

    // state_mult should be 1.0 for merged MRs
    for row in &rows {
        assert!(
            (row.5 - 1.0).abs() < f64::EPSILON,
            "merged MR should have state_mult=1.0, got {}",
            row.5
        );
    }
}

#[test]
fn test_expert_sql_v2_reviewer_split() {
    let conn = setup_test_db();
    let now = now_ms();
    insert_project(&conn, 1, "team/backend");
    insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None);
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    // reviewer_b leaves a substantive note (>= 20 chars)
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "reviewer_b",
        "src/app.rs",
        "This looks correct, good refactoring work here",
    );
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_reviewer(&conn, 1, "reviewer_b");
    // reviewer_c is assigned but leaves no notes
    insert_reviewer(&conn, 1, "reviewer_c");

    let sql = build_expert_sql_v2(false);
    let mut stmt = conn.prepare_cached(&sql).unwrap();
    let rows: Vec<(String, String)> = stmt
        .query_map(
            rusqlite::params![
                "src/app.rs",
                0_i64,
                Option::<i64>::None,
                now + 1000,
                0.5_f64,
                20_i64,
            ],
            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
        )
        .unwrap()
        .collect::<std::result::Result<Vec<_>, _>>()
        .unwrap();

    // reviewer_b should be "file_reviewer_participated"
    let b_signals: Vec<&str> = rows
        .iter()
        .filter(|r| r.0 == "reviewer_b")
        .map(|r| r.1.as_str())
        .collect();
    assert!(
        b_signals.contains(&"file_reviewer_participated"),
        "reviewer_b should be participated, got: {:?}",
        b_signals
    );
    assert!(
        !b_signals.contains(&"file_reviewer_assigned"),
        "reviewer_b should NOT be assigned-only"
    );

    // reviewer_c should be "file_reviewer_assigned"
    let c_signals: Vec<&str> = rows
        .iter()
        .filter(|r| r.0 == "reviewer_c")
        .map(|r| r.1.as_str())
        .collect();
    assert!(
        c_signals.contains(&"file_reviewer_assigned"),
        "reviewer_c should be assigned-only, got: {:?}",
        c_signals
    );
    assert!(
        !c_signals.contains(&"file_reviewer_participated"),
        "reviewer_c should NOT be participated"
    );
}

#[test]
fn test_expert_sql_v2_closed_mr_multiplier() {
    let conn = setup_test_db();
    let now = now_ms();
    insert_project(&conn, 1, "team/backend");
    insert_mr_at(&conn, 1, 1, 100, "author_a", "closed", now, None, Some(now));
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");

    let sql = build_expert_sql_v2(false);
    let mut stmt = conn.prepare_cached(&sql).unwrap();
    let rows: Vec<(String, String, f64)> = stmt
        .query_map(
            rusqlite::params![
                "src/app.rs",
                0_i64,
                Option::<i64>::None,
                now + 1000,
                0.5_f64,
                20_i64,
            ],
            |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, f64>(5)?,
                ))
            },
        )
        .unwrap()
        .collect::<std::result::Result<Vec<_>, _>>()
        .unwrap();

    // All signals from closed MR should have state_mult=0.5
    for row in &rows {
        assert!(
            (row.2 - 0.5).abs() < f64::EPSILON,
            "closed MR should have state_mult=0.5, got {} for signal {}",
            row.2,
            row.1
        );
    }
}

#[test]
fn test_expert_v2_decay_scoring() {
    let conn = setup_test_db();
    let now = now_ms();
    let day_ms = 86_400_000_i64;
    insert_project(&conn, 1, "team/backend");

    // Recent author: 10 days ago
    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "recent_author",
        "merged",
        now - 10 * day_ms,
        Some(now - 10 * day_ms),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");

    // Old author: 360 days ago
    insert_mr_at(
        &conn,
        2,
        1,
        101,
        "old_author",
        "merged",
        now - 360 * day_ms,
        Some(now - 360 * day_ms),
        None,
    );
    insert_file_change(&conn, 2, 1, "src/app.rs", "modified");

    let scoring = default_scoring();
    let result = query_expert(
        &conn,
        "src/app.rs",
        None,
        0,
        now + 1000,
        20,
        &scoring,
        false,
        false,
        false,
    )
    .unwrap();

    assert_eq!(result.experts.len(), 2);
    // Recent author should rank first
    assert_eq!(result.experts[0].username, "recent_author");
    assert_eq!(result.experts[1].username, "old_author");
    // Recent author score should be much higher than old author
    assert!(result.experts[0].score > result.experts[1].score);
}

#[test]
fn test_expert_v2_reviewer_split_scoring() {
    let conn = setup_test_db();
    let now = now_ms();
    let day_ms = 86_400_000_i64;
    insert_project(&conn, 1, "team/backend");

    // MR from 30 days ago
    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "the_author",
        "merged",
        now - 30 * day_ms,
        Some(now - 30 * day_ms),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);

    // Reviewer A: participated (left substantive DiffNotes)
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "participated_reviewer",
        "src/app.rs",
        "Substantive review comment here about the code",
    );
    insert_reviewer(&conn, 1, "participated_reviewer");

    // Reviewer B: assigned only (no DiffNotes)
    insert_reviewer(&conn, 1, "assigned_reviewer");

    let scoring = default_scoring();
    let result = query_expert(
        &conn,
        "src/app.rs",
        None,
        0,
        now + 1000,
        20,
        &scoring,
        false,
        false,
        false,
    )
    .unwrap();

    let participated = result
        .experts
        .iter()
        .find(|e| e.username == "participated_reviewer");
    let assigned = result
        .experts
        .iter()
        .find(|e| e.username == "assigned_reviewer");
    assert!(
        participated.is_some(),
        "participated reviewer should appear"
    );
    assert!(assigned.is_some(), "assigned reviewer should appear");

    // Participated reviewer should score higher (weight=10 vs weight=3)
    assert!(
        participated.unwrap().score > assigned.unwrap().score,
        "participated ({}) should score higher than assigned ({})",
        participated.unwrap().score,
        assigned.unwrap().score
    );
}

#[test]
fn test_expert_v2_excluded_usernames() {
    let conn = setup_test_db();
    let now = now_ms();
    insert_project(&conn, 1, "team/backend");
    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "real_user",
        "merged",
        now,
        Some(now),
        None,
    );
    insert_mr_at(
        &conn,
        2,
        1,
        101,
        "renovate-bot",
        "merged",
        now,
        Some(now),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_file_change(&conn, 2, 1, "src/app.rs", "modified");

    let mut scoring = default_scoring();
    scoring.excluded_usernames = vec!["renovate-bot".to_string()];
    let result = query_expert(
        &conn,
        "src/app.rs",
        None,
        0,
        now + 1000,
        20,
        &scoring,
        false,
        false,
        false,
    )
    .unwrap();

    let usernames: Vec<&str> = result.experts.iter().map(|e| e.username.as_str()).collect();
    assert!(usernames.contains(&"real_user"));
    assert!(
        !usernames.contains(&"renovate-bot"),
        "bot should be excluded"
    );
}

#[test]
fn test_expert_v2_deterministic_ordering() {
    let conn = setup_test_db();
    let now = now_ms();
    let day_ms = 86_400_000_i64;
    insert_project(&conn, 1, "team/backend");

    // Create 5 MRs with varied timestamps
    for i in 0_i64..5 {
        insert_mr_at(
            &conn,
            i + 1,
            1,
            100 + i,
            "test_user",
            "merged",
            now - (i + 1) * 30 * day_ms,
            Some(now - (i + 1) * 30 * day_ms),
            None,
        );
        insert_file_change(&conn, i + 1, 1, "src/app.rs", "modified");
    }

    let scoring = default_scoring();
    // Run 10 times and verify identical scores
    let mut scores: Vec<i64> = Vec::new();
    for _ in 0..10 {
        let result = query_expert(
            &conn,
            "src/app.rs",
            None,
            0,
            now + 1000,
            20,
            &scoring,
            false,
            false,
            false,
        )
        .unwrap();
        assert_eq!(result.experts.len(), 1);
        scores.push(result.experts[0].score);
    }
    assert!(
        scores.windows(2).all(|w| w[0] == w[1]),
        "scores should be identical across runs: {:?}",
        scores
    );
}

#[test]
fn test_expert_detail_mode() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 891, "author_a", "merged");
    insert_mr(&conn, 2, 1, 902, "author_a", "merged");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_discussion(&conn, 2, 1, Some(2), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1");
    insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/auth/login.rs", "note2");
    insert_diffnote(&conn, 3, 2, 1, "reviewer_b", "src/auth/session.rs", "note3");

    // Without detail: details should be None
    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();
    for expert in &result.experts {
        assert!(expert.details.is_none());
    }

    // With detail: details should be populated
    let result = query_expert(
        &conn,
        "src/auth/",
        None,
        0,
        test_as_of_ms(),
        20,
        &default_scoring(),
        true,
        false,
        false,
    )
    .unwrap();
    let reviewer = result
        .experts
        .iter()
        .find(|e| e.username == "reviewer_b")
        .unwrap();
    let details = reviewer.details.as_ref().unwrap();
    assert!(!details.is_empty());

    // All detail entries should have role "R" for reviewer
    for d in details {
        assert!(
            d.role == "R" || d.role == "A+R",
            "role should be R or A+R, got {}",
            d.role
        );
        assert!(d.mr_ref.starts_with("team/backend!"));
    }

    // author_a should have detail entries with role "A"
    let author = result
        .experts
        .iter()
        .find(|e| e.username == "author_a")
        .unwrap();
    let author_details = author.details.as_ref().unwrap();
    assert!(!author_details.is_empty());
    for d in author_details {
        assert!(
            d.role == "A" || d.role == "A+R",
            "role should be A or A+R, got {}",
            d.role
        );
    }
}

#[test]
fn test_old_path_probe_exact_and_prefix() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change_with_old_path(
        &conn,
        1,
        1,
        "src/new/foo.rs",
        Some("src/old/foo.rs"),
        "renamed",
    );
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote_at(
        &conn,
        1,
        1,
        1,
        "alice",
        "src/new/foo.rs",
        Some("src/old/foo.rs"),
        "review comment",
        now_ms(),
    );

    // Exact probe by OLD path should resolve
    let pq = build_path_query(&conn, "src/old/foo.rs", None).unwrap();
    assert!(!pq.is_prefix, "old exact path should resolve as exact");
    assert_eq!(pq.value, "src/old/foo.rs");

    // Prefix probe by OLD directory should resolve
    let pq = build_path_query(&conn, "src/old/", None).unwrap();
    assert!(pq.is_prefix, "old directory should resolve as prefix");

    // New path still works
    let pq = build_path_query(&conn, "src/new/foo.rs", None).unwrap();
    assert!(!pq.is_prefix, "new exact path should still resolve");
    assert_eq!(pq.value, "src/new/foo.rs");
}

#[test]
fn test_suffix_probe_uses_old_path_sources() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change_with_old_path(
        &conn,
        1,
        1,
        "src/utils.rs",
        Some("legacy/utils.rs"),
        "renamed",
    );

    let result = suffix_probe(&conn, "utils.rs", None).unwrap();
    match result {
        SuffixResult::Ambiguous(paths) => {
            assert!(
                paths.contains(&"src/utils.rs".to_string()),
                "should contain new path"
            );
            assert!(
                paths.contains(&"legacy/utils.rs".to_string()),
                "should contain old path"
            );
        }
        SuffixResult::Unique(p) => {
            panic!("Expected Ambiguous with both paths, got Unique({p})");
        }
        SuffixResult::NoMatch => panic!("Expected Ambiguous, got NoMatch"),
        SuffixResult::NotAttempted => panic!("Expected Ambiguous, got NotAttempted"),
    }
}

// ─── Plan Section 8: New Tests ──────────────────────────────────────────────

#[test]
fn test_expert_scores_decay_with_time() {
    // Two authors, one recent (10 days), one old (360 days).
    // With default author_half_life_days=180, recent ≈ 24.1, old ≈ 6.3.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let day_ms: i64 = 86_400_000;
    let recent_ts = now - 10 * day_ms; // 10 days ago
    let old_ts = now - 360 * day_ms; // 360 days ago

    // Recent author
    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "recent_author",
        "merged",
        recent_ts,
        Some(recent_ts),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");

    // Old author
    insert_mr_at(
        &conn,
        2,
        1,
        200,
        "old_author",
        "merged",
        old_ts,
        Some(old_ts),
        None,
    );
    insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    assert_eq!(result.experts.len(), 2);
    assert_eq!(result.experts[0].username, "recent_author");
    assert_eq!(result.experts[1].username, "old_author");

    // Recent author scores significantly higher
    let recent_score = result.experts[0].score_raw.unwrap();
    let old_score = result.experts[1].score_raw.unwrap();
    assert!(
        recent_score > old_score * 2.0,
        "recent ({recent_score:.1}) should be >2x old ({old_score:.1})"
    );
}

#[test]
fn test_expert_reviewer_decays_faster_than_author() {
    // Same MR, same age (90 days). Author half-life=180d, reviewer half-life=90d.
    // Author retains 2^(-90/180)=0.707 of weight, reviewer retains 2^(-90/90)=0.5.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let day_ms: i64 = 86_400_000;
    let age_ts = now - 90 * day_ms;

    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "the_author",
        "merged",
        age_ts,
        Some(age_ts),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote_at(
        &conn,
        1,
        1,
        1,
        "the_reviewer",
        "src/lib.rs",
        None,
        "a substantive review comment here",
        age_ts,
    );
    insert_reviewer(&conn, 1, "the_reviewer");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    // Author gets file_author (25*0.707) + diffnote_author (25*0.707) ≈ 35.4
    // Reviewer gets file_reviewer_participated (10*0.5) + note_group (1*1.0*0.5) ≈ 5.5
    assert_eq!(result.experts[0].username, "the_author");
    let author_score = result.experts[0].score_raw.unwrap();
    let reviewer_score = result.experts[1].score_raw.unwrap();
    assert!(
        author_score > reviewer_score * 3.0,
        "author ({author_score:.1}) should dominate reviewer ({reviewer_score:.1})"
    );
}

#[test]
fn test_reviewer_participated_vs_assigned_only() {
    // Two reviewers on the same MR. One left substantive DiffNotes (participated),
    // one didn't (assigned-only). Participated gets reviewer_weight, assigned-only
    // gets reviewer_assignment_weight (much lower).
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    insert_mr(&conn, 1, 1, 100, "the_author", "merged");
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "active_reviewer",
        "src/lib.rs",
        "This needs refactoring because...",
    );
    insert_reviewer(&conn, 1, "active_reviewer");
    insert_reviewer(&conn, 1, "passive_reviewer");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    let active = result
        .experts
        .iter()
        .find(|e| e.username == "active_reviewer")
        .unwrap();
    let passive = result
        .experts
        .iter()
        .find(|e| e.username == "passive_reviewer")
        .unwrap();
    let active_score = active.score_raw.unwrap();
    let passive_score = passive.score_raw.unwrap();

    // Default: reviewer_weight=10, reviewer_assignment_weight=3
    // Active: 10 * ~1.0 + note_group ≈ 11
    // Passive: 3 * ~1.0 = 3
    assert!(
        active_score > passive_score * 2.0,
        "active ({active_score:.1}) should be >2x passive ({passive_score:.1})"
    );
}

#[test]
fn test_note_diminishing_returns_per_mr() {
    // One reviewer with 1 note on MR-A and another with 20 notes on MR-B.
    // The 20-note reviewer should score ~log2(21)/log2(2) ≈ 4.4x, NOT 20x.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    // MR 1 with 1 note
    insert_mr(&conn, 1, 1, 100, "author_x", "merged");
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "one_note_reviewer",
        "src/lib.rs",
        "a single substantive review note",
    );
    insert_reviewer(&conn, 1, "one_note_reviewer");

    // MR 2 with 20 notes from another reviewer
    insert_mr(&conn, 2, 1, 200, "author_y", "merged");
    insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");
    insert_discussion(&conn, 2, 1, Some(2), None, true, false);
    for i in 0_i64..20 {
        insert_diffnote(
            &conn,
            100 + i,
            2,
            1,
            "many_note_reviewer",
            "src/lib.rs",
            &format!("substantive review comment number {i}"),
        );
    }
    insert_reviewer(&conn, 2, "many_note_reviewer");

    let scoring = ScoringConfig {
        reviewer_weight: 0,
        reviewer_assignment_weight: 0,
        author_weight: 0,
        note_bonus: 10, // High bonus to isolate note contribution
        ..Default::default()
    };
    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &scoring,
        false,
        true,
        false,
    )
    .unwrap();

    let one = result
        .experts
        .iter()
        .find(|e| e.username == "one_note_reviewer")
        .unwrap();
    let many = result
        .experts
        .iter()
        .find(|e| e.username == "many_note_reviewer")
        .unwrap();
    let one_score = one.score_raw.unwrap();
    let many_score = many.score_raw.unwrap();

    // log2(1+1)=1.0, log2(1+20)≈4.39. Ratio should be ~4.4x, not 20x.
    let ratio = many_score / one_score;
    assert!(
        ratio < 6.0,
        "ratio ({ratio:.1}) should be ~4.4, not 20 (diminishing returns)"
    );
    assert!(
        ratio > 3.0,
        "ratio ({ratio:.1}) should be ~4.4, reflecting log2 scaling"
    );
}

#[test]
fn test_file_change_timestamp_uses_merged_at() {
    // A merged MR should use merged_at for decay, not updated_at.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let day_ms: i64 = 86_400_000;
    let old_merged = now - 300 * day_ms; // merged 300 days ago
    let recent_updated = now - day_ms; // updated yesterday

    // MR merged long ago but recently updated (e.g., label change)
    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "the_author",
        "merged",
        recent_updated,
        Some(old_merged),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    assert_eq!(result.experts.len(), 1);
    let score = result.experts[0].score_raw.unwrap();
    // With half_life=180d and elapsed=300d, decay = 2^(-300/180) ≈ 0.315
    // Score ≈ 25 * 0.315 ≈ 7.9 (file_author only, no diffnote_author without notes)
    // If it incorrectly used updated_at (1 day), score ≈ 25 * ~1.0 = 25
    assert!(
        score < 15.0,
        "score ({score:.1}) should reflect old merged_at, not recent updated_at"
    );
}

#[test]
fn test_open_mr_uses_updated_at() {
    // An opened MR should use updated_at for decay, not created_at.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let day_ms: i64 = 86_400_000;

    // MR-A: opened, recently updated (decay ≈ 1.0)
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at)
         VALUES (1, 10, 1, 100, 'MR 100', 'recent_update', 'opened', ?1, ?2, ?3)",
        rusqlite::params![now, now - 5 * day_ms, now - 200 * day_ms],
    ).unwrap();
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");

    // MR-B: opened, old updated_at (decay significant)
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at)
         VALUES (2, 20, 1, 200, 'MR 200', 'old_update', 'opened', ?1, ?2, ?3)",
        rusqlite::params![now, now - 200 * day_ms, now - 200 * day_ms],
    ).unwrap();
    insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    // recent_update should rank first (higher score from fresher updated_at)
    assert_eq!(result.experts[0].username, "recent_update");
    let recent = result.experts[0].score_raw.unwrap();
    let old = result.experts[1].score_raw.unwrap();
    assert!(
        recent > old * 2.0,
        "recent ({recent:.1}) should beat old ({old:.1}) by updated_at"
    );
}

#[test]
fn test_old_path_match_credits_expertise() {
    // DiffNote with old_path should credit expertise when queried by old path.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    insert_mr(&conn, 1, 1, 100, "the_author", "merged");
    insert_file_change_with_old_path(&conn, 1, 1, "src/new.rs", Some("src/old.rs"), "renamed");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote_at(
        &conn,
        1,
        1,
        1,
        "reviewer_a",
        "src/new.rs",
        Some("src/old.rs"),
        "substantive review of the renamed file",
        now,
    );

    // Query by old path
    let result_old = query_expert(
        &conn,
        "src/old.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();

    // Query by new path
    let result_new = query_expert(
        &conn,
        "src/new.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();

    // Both queries should find the author
    assert!(
        result_old
            .experts
            .iter()
            .any(|e| e.username == "the_author"),
        "author should appear via old_path query"
    );
    assert!(
        result_new
            .experts
            .iter()
            .any(|e| e.username == "the_author"),
        "author should appear via new_path query"
    );
}

#[test]
fn test_explain_score_components_sum_to_total() {
    // With explain_score, component subtotals should sum to score_raw.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    insert_mr(&conn, 1, 1, 100, "the_author", "merged");
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "the_reviewer",
        "src/lib.rs",
        "a substantive enough review comment",
    );
    insert_reviewer(&conn, 1, "the_reviewer");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    for expert in &result.experts {
        let raw = expert.score_raw.unwrap();
        let comp = expert.components.as_ref().unwrap();
        let sum = comp.author + comp.reviewer_participated + comp.reviewer_assigned + comp.notes;
        assert!(
            (raw - sum).abs() < 1e-10,
            "components ({sum:.6}) should sum to score_raw ({raw:.6}) for {}",
            expert.username
        );
    }
}

#[test]
fn test_as_of_produces_deterministic_results() {
    // Same as_of value produces identical results across multiple runs.
    // Later as_of produces lower scores (more decay).
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let day_ms: i64 = 86_400_000;
    let mr_ts = now - 30 * day_ms;

    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "the_author",
        "merged",
        mr_ts,
        Some(mr_ts),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");

    let as_of_early = now;
    let as_of_late = now + 180 * day_ms;

    let result1 = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        as_of_early,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();
    let result2 = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        as_of_early,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    // Same as_of -> identical scores
    assert_eq!(
        result1.experts[0].score_raw.unwrap().to_bits(),
        result2.experts[0].score_raw.unwrap().to_bits(),
        "same as_of should produce bit-identical scores"
    );

    // Later as_of -> lower score
    let result_late = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        as_of_late,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();
    assert!(
        result1.experts[0].score_raw.unwrap() > result_late.experts[0].score_raw.unwrap(),
        "later as_of should produce lower scores"
    );
}

#[test]
fn test_trivial_note_does_not_count_as_participation() {
    // A reviewer with only a short note ("LGTM") should be classified as
    // assigned-only, not participated, when reviewer_min_note_chars = 20.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    insert_mr(&conn, 1, 1, 100, "the_author", "merged");
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    // Short note (4 chars, below threshold of 20)
    insert_diffnote(&conn, 1, 1, 1, "trivial_reviewer", "src/lib.rs", "LGTM");
    insert_reviewer(&conn, 1, "trivial_reviewer");

    // Another reviewer with substantive note
    insert_diffnote(
        &conn,
        2,
        1,
        1,
        "substantive_reviewer",
        "src/lib.rs",
        "This function needs better error handling for the edge case...",
    );
    insert_reviewer(&conn, 1, "substantive_reviewer");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    let trivial = result
        .experts
        .iter()
        .find(|e| e.username == "trivial_reviewer")
        .unwrap();
    let substantive = result
        .experts
        .iter()
        .find(|e| e.username == "substantive_reviewer")
        .unwrap();

    let trivial_comp = trivial.components.as_ref().unwrap();
    let substantive_comp = substantive.components.as_ref().unwrap();

    // Trivial should get reviewer_assigned (3), not reviewer_participated (10)
    assert!(
        trivial_comp.reviewer_assigned > 0.0,
        "trivial reviewer should get assigned-only signal"
    );
    assert!(
        trivial_comp.reviewer_participated < 0.01,
        "trivial reviewer should NOT get participated signal"
    );

    // Substantive should get reviewer_participated
    assert!(
        substantive_comp.reviewer_participated > 0.0,
        "substantive reviewer should get participated signal"
    );
}

#[test]
fn test_closed_mr_multiplier() {
    // Two MRs with the same author: one merged, one closed.
    // Closed should contribute author_weight * closed_mr_multiplier * decay.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    // Merged MR
    insert_mr(&conn, 1, 1, 100, "merged_author", "merged");
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");

    // Closed MR (same path, different author)
    insert_mr(&conn, 2, 1, 200, "closed_author", "closed");
    insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    let merged = result
        .experts
        .iter()
        .find(|e| e.username == "merged_author")
        .unwrap();
    let closed = result
        .experts
        .iter()
        .find(|e| e.username == "closed_author")
        .unwrap();
    let merged_score = merged.score_raw.unwrap();
    let closed_score = closed.score_raw.unwrap();

    // Default closed_mr_multiplier=0.5, so closed should be roughly half
    let ratio = closed_score / merged_score;
    assert!(
        (ratio - 0.5).abs() < 0.1,
        "closed/merged ratio ({ratio:.2}) should be ≈0.5"
    );
}

#[test]
fn test_as_of_excludes_future_events() {
    // Events after as_of should not appear in results.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let day_ms: i64 = 86_400_000;
    let past_ts = now - 30 * day_ms;
    let future_ts = now + 30 * day_ms;

    // Past MR (should appear)
    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "past_author",
        "merged",
        past_ts,
        Some(past_ts),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");

    // Future MR (should NOT appear)
    insert_mr_at(
        &conn,
        2,
        1,
        200,
        "future_author",
        "merged",
        future_ts,
        Some(future_ts),
        None,
    );
    insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now,
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();

    assert!(
        result.experts.iter().any(|e| e.username == "past_author"),
        "past author should appear"
    );
    assert!(
        !result.experts.iter().any(|e| e.username == "future_author"),
        "future author should be excluded by as_of"
    );
}

#[test]
fn test_as_of_exclusive_upper_bound() {
    // An event with timestamp exactly equal to as_of should be excluded (strict <).
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let boundary_ts = now;

    insert_mr_at(
        &conn,
        1,
        1,
        100,
        "boundary_author",
        "merged",
        boundary_ts,
        Some(boundary_ts),
        None,
    );
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        boundary_ts,
        20,
        &default_scoring(),
        false,
        false,
        false,
    )
    .unwrap();

    assert!(
        !result
            .experts
            .iter()
            .any(|e| e.username == "boundary_author"),
        "event at exactly as_of should be excluded (half-open interval)"
    );
}

#[test]
fn test_excluded_usernames_filters_bots() {
    // Bot users in excluded_usernames should be filtered out.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged");
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
    insert_mr(&conn, 2, 1, 200, "jsmith", "merged");
    insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");

    let scoring = ScoringConfig {
        excluded_usernames: vec!["renovate-bot".to_string()],
        ..Default::default()
    };
    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &scoring,
        false,
        false,
        false,
    )
    .unwrap();

    assert_eq!(result.experts.len(), 1);
    assert_eq!(result.experts[0].username, "jsmith");
}

#[test]
fn test_include_bots_flag_disables_filtering() {
    // With include_bots=true, excluded_usernames should be ignored.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged");
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
    insert_mr(&conn, 2, 1, 200, "jsmith", "merged");
    insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");

    let scoring = ScoringConfig {
        excluded_usernames: vec!["renovate-bot".to_string()],
        ..Default::default()
    };
    // include_bots = true
    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &scoring,
        false,
        false,
        true,
    )
    .unwrap();

    assert_eq!(result.experts.len(), 2);
    assert!(result.experts.iter().any(|e| e.username == "renovate-bot"));
    assert!(result.experts.iter().any(|e| e.username == "jsmith"));
}

#[test]
fn test_null_timestamp_fallback_to_created_at() {
    // A merged MR with merged_at=NULL should fall back to created_at.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let day_ms: i64 = 86_400_000;
    let old_ts = now - 100 * day_ms;

    // Insert merged MR with merged_at=NULL, created_at set
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, created_at, merged_at, updated_at)
         VALUES (1, 10, 1, 100, 'MR 100', 'the_author', 'merged', ?1, ?2, NULL, NULL)",
        rusqlite::params![now, old_ts],
    ).unwrap();
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    // Should still find the author (not panic or return empty)
    assert_eq!(result.experts.len(), 1);
    assert_eq!(result.experts[0].username, "the_author");
    // Score should reflect old_ts (100 days ago), not 0 or now
    let score = result.experts[0].score_raw.unwrap();
    // 25 * 2^(-100/180) ≈ 17.1
    assert!(
        score > 5.0 && score < 22.0,
        "score ({score:.1}) should reflect created_at fallback"
    );
}

// ─── Invariant Tests ────────────────────────────────────────────────────────

#[test]
fn test_score_monotonicity_by_age() {
    // For any single signal, older timestamp must never produce a higher score.
    for half_life in [1_u32, 7, 45, 90, 180, 365] {
        let mut prev_decay = f64::MAX;
        for days in 0..=730 {
            let elapsed_ms = i64::from(days) * 86_400_000;
            let decay = half_life_decay(elapsed_ms, half_life);
            assert!(
                decay <= prev_decay,
                "monotonicity violated: half_life={half_life}, day={days}, decay={decay} > prev={prev_decay}"
            );
            prev_decay = decay;
        }
    }
}

#[test]
fn test_row_order_independence() {
    // Same signals inserted in different order should produce identical results.
    let conn1 = setup_test_db();
    let conn2 = setup_test_db();

    let now = now_ms();
    let day_ms: i64 = 86_400_000;

    // Setup identical data in both DBs but in different insertion order
    for conn in [&conn1, &conn2] {
        insert_project(conn, 1, "team/backend");
    }

    // Forward order in conn1
    for i in 1_i64..=5 {
        let ts = now - i * 30 * day_ms;
        insert_mr_at(
            &conn1,
            i,
            1,
            i * 100,
            &format!("author_{i}"),
            "merged",
            ts,
            Some(ts),
            None,
        );
        insert_file_change(&conn1, i, 1, "src/lib.rs", "modified");
    }

    // Reverse order in conn2
    for i in (1_i64..=5).rev() {
        let ts = now - i * 30 * day_ms;
        insert_mr_at(
            &conn2,
            i,
            1,
            i * 100,
            &format!("author_{i}"),
            "merged",
            ts,
            Some(ts),
            None,
        );
        insert_file_change(&conn2, i, 1, "src/lib.rs", "modified");
    }

    let r1 = query_expert(
        &conn1,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();
    let r2 = query_expert(
        &conn2,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    assert_eq!(r1.experts.len(), r2.experts.len());
    for (e1, e2) in r1.experts.iter().zip(r2.experts.iter()) {
        assert_eq!(e1.username, e2.username, "ranking order differs");
        assert_eq!(
            e1.score_raw.unwrap().to_bits(),
            e2.score_raw.unwrap().to_bits(),
            "scores differ for {}",
            e1.username
        );
    }
}

#[test]
fn test_reviewer_split_is_exhaustive() {
    // A reviewer on an MR must appear in exactly one of: participated or assigned-only.
    // Three cases: (1) substantive notes -> participated, (2) no notes -> assigned,
    // (3) trivial notes -> assigned.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    insert_mr(&conn, 1, 1, 100, "the_author", "merged");
    insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);

    // Case 1: substantive notes
    insert_diffnote(
        &conn,
        1,
        1,
        1,
        "reviewer_substantive",
        "src/lib.rs",
        "this is a long enough substantive review comment",
    );
    insert_reviewer(&conn, 1, "reviewer_substantive");

    // Case 2: no notes at all
    insert_reviewer(&conn, 1, "reviewer_no_notes");

    // Case 3: trivial notes only
    insert_diffnote(&conn, 2, 1, 1, "reviewer_trivial", "src/lib.rs", "ok");
    insert_reviewer(&conn, 1, "reviewer_trivial");

    let result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        now + 1000,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();

    // Check each reviewer's components
    let subst = result
        .experts
        .iter()
        .find(|e| e.username == "reviewer_substantive")
        .unwrap();
    let subst_comp = subst.components.as_ref().unwrap();
    assert!(
        subst_comp.reviewer_participated > 0.0,
        "case 1: should be participated"
    );
    assert!(
        subst_comp.reviewer_assigned < 0.01,
        "case 1: should NOT be assigned"
    );

    let no_notes = result
        .experts
        .iter()
        .find(|e| e.username == "reviewer_no_notes")
        .unwrap();
    let nn_comp = no_notes.components.as_ref().unwrap();
    assert!(
        nn_comp.reviewer_assigned > 0.0,
        "case 2: should be assigned"
    );
    assert!(
        nn_comp.reviewer_participated < 0.01,
        "case 2: should NOT be participated"
    );

    let trivial = result
        .experts
        .iter()
        .find(|e| e.username == "reviewer_trivial")
        .unwrap();
    let tr_comp = trivial.components.as_ref().unwrap();
    assert!(
        tr_comp.reviewer_assigned > 0.0,
        "case 3: should be assigned"
    );
    assert!(
        tr_comp.reviewer_participated < 0.01,
        "case 3: should NOT be participated"
    );
}

#[test]
fn test_deterministic_accumulation_order() {
    // Same data queried 50 times must produce bit-identical f64 scores.
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");

    let now = now_ms();
    let day_ms: i64 = 86_400_000;

    // 10 MRs at varied ages
    for i in 1_i64..=10 {
        let ts = now - i * 20 * day_ms;
        insert_mr_at(
            &conn,
            i,
            1,
            i * 100,
            "the_author",
            "merged",
            ts,
            Some(ts),
            None,
        );
        insert_file_change(&conn, i, 1, "src/lib.rs", "modified");
    }

    let as_of = now + 1000;
    let first_result = query_expert(
        &conn,
        "src/lib.rs",
        None,
        0,
        as_of,
        20,
        &default_scoring(),
        false,
        true,
        false,
    )
    .unwrap();
    let expected_bits = first_result.experts[0].score_raw.unwrap().to_bits();

    for run in 1..50 {
        let result = query_expert(
            &conn,
            "src/lib.rs",
            None,
            0,
            as_of,
            20,
            &default_scoring(),
            false,
            true,
            false,
        )
        .unwrap();
        assert_eq!(
            result.experts[0].score_raw.unwrap().to_bits(),
            expected_bits,
            "run {run}: score bits diverged (HashMap iteration order leaking)"
        );
    }
}

// ─── Tests: include_closed filter ────────────────────────────────────────────

#[test]
fn workload_excludes_closed_entity_discussions() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");

    // Open issue with unresolved discussion
    insert_issue_with_state(&conn, 10, 1, 10, "someone", "opened");
    insert_discussion(&conn, 100, 1, None, Some(10), true, false);
    insert_note(&conn, 1000, 100, 1, "alice");

    // Closed issue with unresolved discussion
    insert_issue_with_state(&conn, 20, 1, 20, "someone", "closed");
    insert_discussion(&conn, 200, 1, None, Some(20), true, false);
    insert_note(&conn, 2000, 200, 1, "alice");

    // Default: exclude closed
    let result = query_workload(&conn, "alice", None, None, 50, false).unwrap();
    assert_eq!(result.unresolved_discussions.len(), 1);
    assert_eq!(result.unresolved_discussions[0].entity_iid, 10);
}

#[test]
fn workload_include_closed_flag_shows_all() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");

    insert_issue_with_state(&conn, 10, 1, 10, "someone", "opened");
    insert_discussion(&conn, 100, 1, None, Some(10), true, false);
    insert_note(&conn, 1000, 100, 1, "alice");

    insert_issue_with_state(&conn, 20, 1, 20, "someone", "closed");
    insert_discussion(&conn, 200, 1, None, Some(20), true, false);
    insert_note(&conn, 2000, 200, 1, "alice");

    let result = query_workload(&conn, "alice", None, None, 50, true).unwrap();
    assert_eq!(result.unresolved_discussions.len(), 2);
}

#[test]
fn workload_excludes_merged_mr_discussions() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");

    // Open MR with unresolved discussion
    insert_mr(&conn, 10, 1, 10, "someone", "opened");
    insert_discussion(&conn, 100, 1, Some(10), None, true, false);
    insert_note(&conn, 1000, 100, 1, "alice");

    // Merged MR with unresolved discussion
    insert_mr(&conn, 20, 1, 20, "someone", "merged");
    insert_discussion(&conn, 200, 1, Some(20), None, true, false);
    insert_note(&conn, 2000, 200, 1, "alice");

    let result = query_workload(&conn, "alice", None, None, 50, false).unwrap();
    assert_eq!(result.unresolved_discussions.len(), 1);
    assert_eq!(result.unresolved_discussions[0].entity_iid, 10);

    // include_closed shows both
    let result = query_workload(&conn, "alice", None, None, 50, true).unwrap();
    assert_eq!(result.unresolved_discussions.len(), 2);
}

#[test]
fn active_excludes_closed_entity_discussions() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");

    // Open issue with unresolved discussion
    insert_issue_with_state(&conn, 10, 1, 10, "someone", "opened");
    insert_discussion(&conn, 100, 1, None, Some(10), true, false);
    insert_note(&conn, 1000, 100, 1, "alice");

    // Closed issue with unresolved discussion
    insert_issue_with_state(&conn, 20, 1, 20, "someone", "closed");
    insert_discussion(&conn, 200, 1, None, Some(20), true, false);
    insert_note(&conn, 2000, 200, 1, "alice");

    // Default: exclude closed
    let result = query_active(&conn, None, 0, 50, false).unwrap();
    assert_eq!(result.discussions.len(), 1);
    assert_eq!(result.discussions[0].entity_iid, 10);
    assert_eq!(result.total_unresolved_in_window, 1);

    // include_closed shows both
    let result = query_active(&conn, None, 0, 50, true).unwrap();
    assert_eq!(result.discussions.len(), 2);
    assert_eq!(result.total_unresolved_in_window, 2);
}

// ─── Regression: Unlimited limit must not overflow ─────────────────────────

#[test]
fn workload_unlimited_limit_returns_results() {
    // Regression test for integer overflow bug: when limit=usize::MAX, the
    // expression (limit + 1) wrapped to 0, causing LIMIT 0 to return no rows.
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 1, 1, 100, "alice");
    insert_assignee(&conn, 1, "alice");

    // usize::MAX simulates the "unlimited" default when --limit is omitted
    let result = query_workload(&conn, "alice", None, None, usize::MAX, false).unwrap();
    assert!(
        !result.assigned_issues.is_empty(),
        "usize::MAX limit should return results, not overflow to LIMIT 0"
    );
}

#[test]
fn active_unlimited_limit_returns_results() {
    // Same regression test for query_active
    let conn = setup_test_db();
    insert_project(&conn, 1, "group/repo");
    insert_issue(&conn, 1, 1, 100, "alice");
    insert_discussion(&conn, 1, 1, None, Some(1), true, false);
    insert_note(&conn, 1, 1, 1, "alice");

    let result = query_active(&conn, None, 0, usize::MAX, false).unwrap();
    assert!(
        !result.discussions.is_empty(),
        "usize::MAX limit should return results, not overflow to LIMIT 0"
    );
}
