use super::*;
use crate::core::db::{create_connection, run_migrations};
use std::path::Path;

fn setup_test_db() -> Connection {
    let conn = create_connection(Path::new(":memory:")).unwrap();
    run_migrations(&conn).unwrap();
    conn
}

fn insert_project(conn: &Connection) -> i64 {
    conn.execute(
        "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')",
        [],
    )
    .unwrap();
    conn.last_insert_rowid()
}

fn insert_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 {
    conn.execute(
        "INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, ?3, 'Auth bug', 'opened', 'alice', 1000, 2000, 3000, 'https://gitlab.com/group/project/-/issues/1')",
        rusqlite::params![iid * 100, project_id, iid],
    )
    .unwrap();
    conn.last_insert_rowid()
}

fn insert_mr(conn: &Connection, project_id: i64, iid: i64, merged_at: Option<i64>) -> i64 {
    conn.execute(
        "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, merged_at, merge_user_username, web_url) VALUES (?1, ?2, ?3, 'Fix auth', 'merged', 'bob', 1000, 5000, 6000, ?4, 'charlie', 'https://gitlab.com/group/project/-/merge_requests/10')",
        rusqlite::params![iid * 100, project_id, iid, merged_at],
    )
    .unwrap();
    conn.last_insert_rowid()
}

fn make_entity_ref(entity_type: &str, entity_id: i64, iid: i64) -> EntityRef {
    EntityRef {
        entity_type: entity_type.to_owned(),
        entity_id,
        entity_iid: iid,
        project_path: "group/project".to_owned(),
    }
}

fn insert_state_event(
    conn: &Connection,
    project_id: i64,
    issue_id: Option<i64>,
    mr_id: Option<i64>,
    state: &str,
    created_at: i64,
) {
    let gitlab_id: i64 = rand::random::<u32>().into();
    conn.execute(
        "INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'alice', ?6)",
        rusqlite::params![gitlab_id, project_id, issue_id, mr_id, state, created_at],
    )
    .unwrap();
}

fn insert_label_event(
    conn: &Connection,
    project_id: i64,
    issue_id: Option<i64>,
    mr_id: Option<i64>,
    action: &str,
    label_name: Option<&str>,
    created_at: i64,
) {
    let gitlab_id: i64 = rand::random::<u32>().into();
    conn.execute(
        "INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)",
        rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, label_name, created_at],
    )
    .unwrap();
}

fn insert_milestone_event(
    conn: &Connection,
    project_id: i64,
    issue_id: Option<i64>,
    mr_id: Option<i64>,
    action: &str,
    milestone_title: Option<&str>,
    created_at: i64,
) {
    let gitlab_id: i64 = rand::random::<u32>().into();
    conn.execute(
        "INSERT INTO resource_milestone_events (gitlab_id, project_id, issue_id, merge_request_id, action, milestone_title, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)",
        rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, milestone_title, created_at],
    )
    .unwrap();
}

#[test]
fn test_collect_creation_event() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let seeds = vec![make_entity_ref("issue", issue_id, 1)];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &[], None, 100).unwrap();
    assert_eq!(events.len(), 1);
    assert!(matches!(events[0].event_type, TimelineEventType::Created));
    assert_eq!(events[0].timestamp, 1000);
    assert_eq!(events[0].actor, Some("alice".to_owned()));
    assert!(events[0].is_seed);
}

#[test]
fn test_collect_state_events() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);

    insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
    insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 4000);

    let seeds = vec![make_entity_ref("issue", issue_id, 1)];
    let (events, _) = collect_events(&conn, &seeds, &[], &[], &[], None, 100).unwrap();

    // Created + 2 state changes = 3
    assert_eq!(events.len(), 3);
    assert!(matches!(events[0].event_type, TimelineEventType::Created));
    assert!(matches!(
        events[1].event_type,
        TimelineEventType::StateChanged { ref state } if state == "closed"
    ));
    assert!(matches!(
        events[2].event_type,
        TimelineEventType::StateChanged { ref state } if state == "reopened"
    ));
}

#[test]
fn test_collect_merged_dedup() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let mr_id = insert_mr(&conn, project_id, 10, Some(5000));

    // Also add a state event for 'merged' — this should NOT produce a StateChanged
    insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000);

    let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
    let (events, _) = collect_events(&conn, &seeds, &[], &[], &[], None, 100).unwrap();

    // Should have Created + Merged (not Created + StateChanged{merged} + Merged)
    let merged_count = events
        .iter()
        .filter(|e| matches!(e.event_type, TimelineEventType::Merged))
        .count();
    let state_merged_count = events
        .iter()
        .filter(|e| matches!(&e.event_type, TimelineEventType::StateChanged { state } if state == "merged"))
        .count();

    assert_eq!(merged_count, 1);
    assert_eq!(state_merged_count, 0);
}

#[test]
fn test_collect_null_label_fallback() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);

    insert_label_event(&conn, project_id, Some(issue_id), None, "add", None, 2000);

    let seeds = vec![make_entity_ref("issue", issue_id, 1)];
    let (events, _) = collect_events(&conn, &seeds, &[], &[], &[], None, 100).unwrap();

    let label_event = events.iter().find(|e| {
        matches!(&e.event_type, TimelineEventType::LabelAdded { label } if label == "[deleted label]")
    });
    assert!(label_event.is_some());
}

#[test]
fn test_collect_null_milestone_fallback() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);

    insert_milestone_event(&conn, project_id, Some(issue_id), None, "add", None, 2000);

    let seeds = vec![make_entity_ref("issue", issue_id, 1)];
    let (events, _) = collect_events(&conn, &seeds, &[], &[], &[], None, 100).unwrap();

    let ms_event = events.iter().find(|e| {
        matches!(&e.event_type, TimelineEventType::MilestoneSet { milestone } if milestone == "[deleted milestone]")
    });
    assert!(ms_event.is_some());
}

#[test]
fn test_collect_since_filter() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);

    insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
    insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 5000);

    let seeds = vec![make_entity_ref("issue", issue_id, 1)];

    // Since 4000: should exclude Created (1000) and closed (3000)
    let (events, _) = collect_events(&conn, &seeds, &[], &[], &[], Some(4000), 100).unwrap();
    assert_eq!(events.len(), 1);
    assert_eq!(events[0].timestamp, 5000);
}

#[test]
fn test_collect_chronological_sort() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let mr_id = insert_mr(&conn, project_id, 10, Some(4000));

    insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
    insert_label_event(
        &conn,
        project_id,
        None,
        Some(mr_id),
        "add",
        Some("bug"),
        2000,
    );

    let seeds = vec![
        make_entity_ref("issue", issue_id, 1),
        make_entity_ref("merge_request", mr_id, 10),
    ];
    let (events, _) = collect_events(&conn, &seeds, &[], &[], &[], None, 100).unwrap();

    // Verify chronological order
    for window in events.windows(2) {
        assert!(window[0].timestamp <= window[1].timestamp);
    }
}

#[test]
fn test_collect_respects_limit() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);

    for i in 0..20 {
        insert_state_event(
            &conn,
            project_id,
            Some(issue_id),
            None,
            "closed",
            3000 + i * 100,
        );
    }

    let seeds = vec![make_entity_ref("issue", issue_id, 1)];
    let (events, total) = collect_events(&conn, &seeds, &[], &[], &[], None, 5).unwrap();
    assert_eq!(events.len(), 5);
    // 20 state changes + 1 created = 21 total before limit
    assert_eq!(total, 21);
}

#[test]
fn test_collect_evidence_notes_included() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);

    let evidence = vec![TimelineEvent {
        timestamp: 2500,
        entity_type: "issue".to_owned(),
        entity_id: issue_id,
        entity_iid: 1,
        project_path: "group/project".to_owned(),
        event_type: TimelineEventType::NoteEvidence {
            note_id: 42,
            snippet: "relevant note".to_owned(),
            discussion_id: Some(1),
        },
        summary: "Note by alice".to_owned(),
        actor: Some("alice".to_owned()),
        url: None,
        is_seed: true,
    }];

    let seeds = vec![make_entity_ref("issue", issue_id, 1)];
    let (events, _) = collect_events(&conn, &seeds, &[], &evidence, &[], None, 100).unwrap();

    let note_event = events.iter().find(|e| {
        matches!(
            &e.event_type,
            TimelineEventType::NoteEvidence { note_id, .. } if *note_id == 42
        )
    });
    assert!(note_event.is_some());
}

#[test]
fn test_collect_merged_fallback_to_state_event() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    // MR with merged_at = NULL
    let mr_id = insert_mr(&conn, project_id, 10, None);

    // But has a state event for 'merged'
    insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000);

    let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
    let (events, _) = collect_events(&conn, &seeds, &[], &[], &[], None, 100).unwrap();

    let merged = events
        .iter()
        .find(|e| matches!(e.event_type, TimelineEventType::Merged));
    assert!(merged.is_some());
    assert_eq!(merged.unwrap().timestamp, 5000);
}

// ─── Discussion thread tests ────────────────────────────────────────────────

fn insert_discussion(
    conn: &Connection,
    project_id: i64,
    issue_id: Option<i64>,
    mr_id: Option<i64>,
) -> i64 {
    let noteable_type = if issue_id.is_some() {
        "Issue"
    } else {
        "MergeRequest"
    };
    conn.execute(
        "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, merge_request_id, noteable_type, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, 0)",
        rusqlite::params![format!("disc_{}", rand::random::<u32>()), project_id, issue_id, mr_id, noteable_type],
    )
    .unwrap();
    conn.last_insert_rowid()
}

#[allow(clippy::too_many_arguments)]
fn insert_note(
    conn: &Connection,
    discussion_id: i64,
    project_id: i64,
    author: &str,
    body: &str,
    is_system: bool,
    created_at: i64,
) -> i64 {
    let gitlab_id: i64 = rand::random::<u32>().into();
    conn.execute(
        "INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, author_username, body, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?7)",
        rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32, author, body, created_at],
    )
    .unwrap();
    conn.last_insert_rowid()
}

fn make_matched_discussion(
    discussion_id: i64,
    entity_type: &str,
    entity_id: i64,
    project_id: i64,
) -> MatchedDiscussion {
    MatchedDiscussion {
        discussion_id,
        entity_type: entity_type.to_owned(),
        entity_id,
        project_id,
    }
}

#[test]
fn test_collect_discussion_thread_basic() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);

    insert_note(
        &conn,
        disc_id,
        project_id,
        "alice",
        "First note",
        false,
        2000,
    );
    insert_note(&conn, disc_id, project_id, "bob", "Reply here", false, 3000);
    insert_note(
        &conn,
        disc_id,
        project_id,
        "alice",
        "Follow up",
        false,
        4000,
    );

    let seeds = [make_entity_ref("issue", issue_id, 1)];
    let discussions = [make_matched_discussion(
        disc_id, "issue", issue_id, project_id,
    )];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &discussions, None, 100).unwrap();

    let thread = events
        .iter()
        .find(|e| matches!(&e.event_type, TimelineEventType::DiscussionThread { .. }));
    assert!(thread.is_some(), "Should have a DiscussionThread event");

    let thread = thread.unwrap();
    if let TimelineEventType::DiscussionThread {
        discussion_id,
        notes,
    } = &thread.event_type
    {
        assert_eq!(*discussion_id, disc_id);
        assert_eq!(notes.len(), 3);
        assert_eq!(notes[0].author.as_deref(), Some("alice"));
        assert_eq!(notes[0].body, "First note");
        assert_eq!(notes[1].author.as_deref(), Some("bob"));
        assert_eq!(notes[2].body, "Follow up");
    } else {
        panic!("Expected DiscussionThread variant");
    }
}

#[test]
fn test_collect_discussion_thread_skips_system_notes() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);

    insert_note(
        &conn,
        disc_id,
        project_id,
        "alice",
        "User note",
        false,
        2000,
    );
    insert_note(
        &conn,
        disc_id,
        project_id,
        "system",
        "added label ~bug",
        true,
        3000,
    );
    insert_note(
        &conn,
        disc_id,
        project_id,
        "bob",
        "Another user note",
        false,
        4000,
    );

    let seeds = [make_entity_ref("issue", issue_id, 1)];
    let discussions = [make_matched_discussion(
        disc_id, "issue", issue_id, project_id,
    )];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &discussions, None, 100).unwrap();

    let thread = events
        .iter()
        .find(|e| matches!(&e.event_type, TimelineEventType::DiscussionThread { .. }));
    assert!(thread.is_some());

    if let TimelineEventType::DiscussionThread { notes, .. } = &thread.unwrap().event_type {
        assert_eq!(notes.len(), 2, "System notes should be filtered out");
        assert_eq!(notes[0].body, "User note");
        assert_eq!(notes[1].body, "Another user note");
    } else {
        panic!("Expected DiscussionThread");
    }
}

#[test]
fn test_collect_discussion_thread_empty_after_system_filter() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);

    // Only system notes
    insert_note(
        &conn,
        disc_id,
        project_id,
        "system",
        "added label",
        true,
        2000,
    );
    insert_note(
        &conn,
        disc_id,
        project_id,
        "system",
        "removed label",
        true,
        3000,
    );

    let seeds = [make_entity_ref("issue", issue_id, 1)];
    let discussions = [make_matched_discussion(
        disc_id, "issue", issue_id, project_id,
    )];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &discussions, None, 100).unwrap();

    let thread_count = events
        .iter()
        .filter(|e| matches!(&e.event_type, TimelineEventType::DiscussionThread { .. }))
        .count();
    assert_eq!(
        thread_count, 0,
        "All-system-note discussion should produce no thread"
    );
}

#[test]
fn test_collect_discussion_thread_body_truncation() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);

    let long_body = "x".repeat(10_000);
    insert_note(&conn, disc_id, project_id, "alice", &long_body, false, 2000);

    let seeds = [make_entity_ref("issue", issue_id, 1)];
    let discussions = [make_matched_discussion(
        disc_id, "issue", issue_id, project_id,
    )];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &discussions, None, 100).unwrap();

    let thread = events
        .iter()
        .find(|e| matches!(&e.event_type, TimelineEventType::DiscussionThread { .. }))
        .unwrap();

    if let TimelineEventType::DiscussionThread { notes, .. } = &thread.event_type {
        assert_eq!(
            notes[0].body.chars().count(),
            10_000,
            "Body should preserve full text without truncation"
        );
    } else {
        panic!("Expected DiscussionThread");
    }
}

#[test]
fn test_collect_discussion_thread_note_cap() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);

    // Insert 60 notes, exceeding THREAD_MAX_NOTES (50)
    for i in 0..60 {
        insert_note(
            &conn,
            disc_id,
            project_id,
            "alice",
            &format!("Note {i}"),
            false,
            2000 + i * 100,
        );
    }

    let seeds = [make_entity_ref("issue", issue_id, 1)];
    let discussions = [make_matched_discussion(
        disc_id, "issue", issue_id, project_id,
    )];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &discussions, None, 100).unwrap();

    let thread = events
        .iter()
        .find(|e| matches!(&e.event_type, TimelineEventType::DiscussionThread { .. }))
        .unwrap();

    if let TimelineEventType::DiscussionThread { notes, .. } = &thread.event_type {
        // 50 notes + 1 synthetic summary = 51
        assert_eq!(
            notes.len(),
            crate::timeline::THREAD_MAX_NOTES + 1,
            "Should cap at THREAD_MAX_NOTES + synthetic summary"
        );
        let last = notes.last().unwrap();
        assert!(last.body.contains("more notes not shown"));
    } else {
        panic!("Expected DiscussionThread");
    }
}

#[test]
fn test_collect_discussion_thread_timestamp_is_first_note() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);

    insert_note(&conn, disc_id, project_id, "alice", "First", false, 5000);
    insert_note(&conn, disc_id, project_id, "bob", "Second", false, 8000);

    let seeds = [make_entity_ref("issue", issue_id, 1)];
    let discussions = [make_matched_discussion(
        disc_id, "issue", issue_id, project_id,
    )];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &discussions, None, 100).unwrap();

    let thread = events
        .iter()
        .find(|e| matches!(&e.event_type, TimelineEventType::DiscussionThread { .. }))
        .unwrap();

    assert_eq!(
        thread.timestamp, 5000,
        "Thread timestamp should be first note's created_at"
    );
}

#[test]
fn test_collect_discussion_thread_sort_position() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);

    // Note at t=2000 (between Created at t=1000 and state change at t=3000)
    insert_note(
        &conn,
        disc_id,
        project_id,
        "alice",
        "discussion",
        false,
        2000,
    );
    insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);

    let seeds = [make_entity_ref("issue", issue_id, 1)];
    let discussions = [make_matched_discussion(
        disc_id, "issue", issue_id, project_id,
    )];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &discussions, None, 100).unwrap();

    // Expected order: Created(1000), DiscussionThread(2000), StateChanged(3000)
    assert!(events.len() >= 3);
    assert!(matches!(events[0].event_type, TimelineEventType::Created));
    assert!(matches!(
        events[1].event_type,
        TimelineEventType::DiscussionThread { .. }
    ));
    assert!(matches!(
        events[2].event_type,
        TimelineEventType::StateChanged { .. }
    ));
}

#[test]
fn test_collect_discussion_thread_dedup() {
    let conn = setup_test_db();
    let project_id = insert_project(&conn);
    let issue_id = insert_issue(&conn, project_id, 1);
    let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);

    insert_note(&conn, disc_id, project_id, "alice", "hello", false, 2000);

    let seeds = [make_entity_ref("issue", issue_id, 1)];
    // Same discussion_id twice
    let discussions = [
        make_matched_discussion(disc_id, "issue", issue_id, project_id),
        make_matched_discussion(disc_id, "issue", issue_id, project_id),
    ];

    let (events, _) = collect_events(&conn, &seeds, &[], &[], &discussions, None, 100).unwrap();

    let thread_count = events
        .iter()
        .filter(|e| matches!(&e.event_type, TimelineEventType::DiscussionThread { .. }))
        .count();
    assert_eq!(
        thread_count, 1,
        "Duplicate discussion_id should produce one thread"
    );
}
