use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessedTicket {
    pub id: String,
    pub tracker_id: Option<String>,
    pub project_id: String,
    pub source: String,
    pub source_ref: Option<String>,
    pub artifact_id: i32,
    pub artifact_title: String,
    pub artifact_data: String,
    pub status: String,
    pub analyst_report: Option<String>,
    pub developer_report: Option<String>,
    pub worktree_path: Option<String>,
    pub branch_name: Option<String>,
    pub detected_at: String,
    pub processed_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectThroughputStats {
    pub backlog_count: i64,
    pub done_last_24h: i64,
    pub error_last_24h: i64,
    pub avg_lead_time_seconds: Option<f64>,
}

fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProcessedTicket> {
    Ok(ProcessedTicket {
        id: row.get(0)?,
        tracker_id: row.get(1)?,
        project_id: row.get(2)?,
        source: row.get(3)?,
        source_ref: row.get(4)?,
        artifact_id: row.get(5)?,
        artifact_title: row.get(6)?,
        artifact_data: row.get(7)?,
        status: row.get(8)?,
        analyst_report: row.get(9)?,
        developer_report: row.get(10)?,
        worktree_path: row.get(11)?,
        branch_name: row.get(12)?,
        detected_at: row.get(13)?,
        processed_at: row.get(14)?,
    })
}

const SELECT_ALL_COLS: &str = "SELECT id, tracker_id, project_id, source, source_ref, \
    artifact_id, artifact_title, artifact_data, status, analyst_report, developer_report, \
    worktree_path, branch_name, detected_at, processed_at FROM processed_tickets";
const SELECT_SUMMARY_COLS: &str = "SELECT id, tracker_id, project_id, source, source_ref, \
    artifact_id, artifact_title, '' AS artifact_data, status, analyst_report, developer_report, \
    worktree_path, branch_name, detected_at, processed_at FROM processed_tickets";

impl ProcessedTicket {
    /// Atomically insert a new ticket keyed by (tracker_id, artifact_id).
    /// Returns Some(ticket) if inserted, None if it was a duplicate.
    pub fn insert_if_new(
        conn: &Connection,
        project_id: &str,
        tracker_id: &str,
        artifact_id: i32,
        artifact_title: &str,
        artifact_data: &str,
    ) -> Result<Option<ProcessedTicket>> {
        let id = Uuid::new_v4().to_string();
        let now = chrono::Utc::now().to_rfc3339();

        let inserted_rows = conn.execute(
            "INSERT OR IGNORE INTO processed_tickets \
             (id, tracker_id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, detected_at) \
             VALUES (?1, ?2, ?3, 'tuleap', NULL, ?4, ?5, ?6, 'Pending', ?7)",
            params![id, tracker_id, project_id, artifact_id, artifact_title, artifact_data, now],
        )?;

        if inserted_rows == 0 {
            return Ok(None);
        }

        let ticket = ProcessedTicket {
            id,
            tracker_id: Some(tracker_id.to_string()),
            project_id: project_id.to_string(),
            source: "tuleap".to_string(),
            source_ref: None,
            artifact_id,
            artifact_title: artifact_title.to_string(),
            artifact_data: artifact_data.to_string(),
            status: "Pending".to_string(),
            analyst_report: None,
            developer_report: None,
            worktree_path: None,
            branch_name: None,
            detected_at: now,
            processed_at: None,
        };

        Ok(Some(ticket))
    }

    pub fn insert_external(
        conn: &Connection,
        project_id: &str,
        source: &str,
        source_ref: Option<&str>,
        artifact_id: i32,
        artifact_title: &str,
        artifact_data: &str,
    ) -> Result<ProcessedTicket> {
        let id = Uuid::new_v4().to_string();
        let now = chrono::Utc::now().to_rfc3339();

        conn.execute(
            "INSERT INTO processed_tickets \
             (id, tracker_id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, detected_at) \
             VALUES (?1, NULL, ?2, ?3, ?4, ?5, ?6, ?7, 'Pending', ?8)",
            params![
                id,
                project_id,
                source,
                source_ref,
                artifact_id,
                artifact_title,
                artifact_data,
                now
            ],
        )?;

        Ok(ProcessedTicket {
            id,
            tracker_id: None,
            project_id: project_id.to_string(),
            source: source.to_string(),
            source_ref: source_ref.map(|value| value.to_string()),
            artifact_id,
            artifact_title: artifact_title.to_string(),
            artifact_data: artifact_data.to_string(),
            status: "Pending".to_string(),
            analyst_report: None,
            developer_report: None,
            worktree_path: None,
            branch_name: None,
            detected_at: now,
            processed_at: None,
        })
    }

    /// Returns true if a ticket with (tracker_id, artifact_id) already exists.
    #[cfg(test)]
    pub fn exists(conn: &Connection, tracker_id: &str, artifact_id: i32) -> Result<bool> {
        let count: i64 = conn.query_row(
            "SELECT COUNT(*) FROM processed_tickets WHERE tracker_id = ?1 AND artifact_id = ?2",
            params![tracker_id, artifact_id],
            |row| row.get(0),
        )?;
        Ok(count > 0)
    }

    #[allow(dead_code)]
    pub fn list_by_tracker(conn: &Connection, tracker_id: &str) -> Result<Vec<ProcessedTicket>> {
        let sql = format!(
            "{} WHERE tracker_id = ?1 ORDER BY detected_at DESC",
            SELECT_ALL_COLS
        );
        let mut stmt = conn.prepare(&sql)?;
        let rows = stmt.query_map(params![tracker_id], from_row)?;
        rows.collect()
    }

    #[allow(dead_code)]
    pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<ProcessedTicket>> {
        let sql = format!(
            "{} WHERE project_id = ?1 ORDER BY detected_at DESC",
            SELECT_ALL_COLS
        );
        let mut stmt = conn.prepare(&sql)?;
        let rows = stmt.query_map(params![project_id], from_row)?;
        rows.collect()
    }

    pub fn list_by_project_summary(
        conn: &Connection,
        project_id: &str,
    ) -> Result<Vec<ProcessedTicket>> {
        let sql = format!(
            "{} WHERE project_id = ?1 ORDER BY detected_at DESC",
            SELECT_SUMMARY_COLS
        );
        let mut stmt = conn.prepare(&sql)?;
        let rows = stmt.query_map(params![project_id], from_row)?;
        rows.collect()
    }

    pub fn get_project_throughput_stats(
        conn: &Connection,
        project_id: &str,
    ) -> Result<ProjectThroughputStats> {
        let window_start = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339();

        conn.query_row(
            "SELECT
                COALESCE(SUM(
                    CASE
                        WHEN pt.status NOT IN ('Done', 'Error', 'Cancelled') THEN 1
                        ELSE 0
                    END
                ), 0) AS backlog_count,
                COALESCE(SUM(
                    CASE
                        WHEN pt.status = 'Done'
                            AND pt.processed_at IS NOT NULL
                            AND julianday(pt.processed_at) >= julianday(?2)
                        THEN 1
                        ELSE 0
                    END
                ), 0) AS done_last_24h,
                COALESCE(SUM(
                    CASE
                        WHEN pt.status = 'Error'
                            AND pt.processed_at IS NOT NULL
                            AND julianday(pt.processed_at) >= julianday(?2)
                        THEN 1
                        ELSE 0
                    END
                ), 0) AS error_last_24h,
                AVG(
                    CASE
                        WHEN pt.status IN ('Done', 'Error')
                            AND pt.processed_at IS NOT NULL
                            AND julianday(pt.processed_at) >= julianday(?2)
                        THEN (julianday(pt.processed_at) - julianday(pt.detected_at)) * 86400.0
                        ELSE NULL
                    END
                ) AS avg_lead_time_seconds
            FROM processed_tickets pt
            WHERE pt.project_id = ?1",
            params![project_id, window_start],
            |row| {
                Ok(ProjectThroughputStats {
                    backlog_count: row.get(0)?,
                    done_last_24h: row.get(1)?,
                    error_last_24h: row.get(2)?,
                    avg_lead_time_seconds: row.get(3)?,
                })
            },
        )
    }

    pub fn get_by_id(conn: &Connection, id: &str) -> Result<ProcessedTicket> {
        let sql = format!("{} WHERE id = ?1", SELECT_ALL_COLS);
        conn.query_row(&sql, params![id], from_row)
    }

    pub fn update_status(conn: &Connection, id: &str, status: &str) -> Result<()> {
        conn.execute(
            "UPDATE processed_tickets SET status = ?1 WHERE id = ?2",
            params![status, id],
        )?;
        Ok(())
    }

    pub fn set_analyst_report(conn: &Connection, id: &str, report: &str) -> Result<()> {
        conn.execute(
            "UPDATE processed_tickets SET analyst_report = ?1 WHERE id = ?2",
            params![report, id],
        )?;
        Ok(())
    }

    pub fn set_developer_report(conn: &Connection, id: &str, report: &str) -> Result<()> {
        let now = chrono::Utc::now().to_rfc3339();
        conn.execute(
            "UPDATE processed_tickets SET developer_report = ?1, processed_at = ?2 WHERE id = ?3",
            params![report, now, id],
        )?;
        Ok(())
    }

    pub fn set_worktree_info(
        conn: &Connection,
        id: &str,
        worktree_path: &str,
        branch_name: &str,
    ) -> Result<()> {
        conn.execute(
            "UPDATE processed_tickets SET worktree_path = ?1, branch_name = ?2 WHERE id = ?3",
            params![worktree_path, branch_name, id],
        )?;
        Ok(())
    }

    pub fn list_pending(conn: &Connection) -> Result<Vec<ProcessedTicket>> {
        let sql = format!(
            "{} WHERE status = 'Pending' ORDER BY detected_at ASC",
            SELECT_ALL_COLS
        );
        let mut stmt = conn.prepare(&sql)?;
        let rows = stmt.query_map([], from_row)?;
        rows.collect()
    }

    pub fn list_inflight(conn: &Connection) -> Result<Vec<ProcessedTicket>> {
        let sql = format!(
            "{} WHERE status IN ('Analyzing', 'Developing') ORDER BY detected_at ASC",
            SELECT_ALL_COLS
        );
        let mut stmt = conn.prepare(&sql)?;
        let rows = stmt.query_map([], from_row)?;
        rows.collect()
    }

    pub fn reset_for_retry(conn: &Connection, id: &str) -> Result<()> {
        conn.execute(
            "UPDATE processed_tickets \
             SET status = 'Pending', analyst_report = NULL, developer_report = NULL, \
                 worktree_path = NULL, branch_name = NULL, processed_at = NULL \
             WHERE id = ?1",
            params![id],
        )?;
        Ok(())
    }

    pub fn set_error(conn: &Connection, id: &str, error_message: &str) -> Result<()> {
        let now = chrono::Utc::now().to_rfc3339();
        conn.execute(
            "UPDATE processed_tickets SET status = 'Error', analyst_report = COALESCE(analyst_report, '') || ?1, processed_at = ?2 WHERE id = ?3",
            params![error_message, now, id],
        )?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db;
    use crate::models::agent::{Agent, AgentRole, AgentTool};
    use crate::models::project::Project;
    use crate::models::tracker::{NewWatchedTracker, WatchedTracker};

    fn setup() -> (Connection, String, String) {
        let conn = db::init_in_memory().expect("db init should succeed");
        let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
        let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap();
        let developer =
            Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap();
        let tracker = WatchedTracker::insert(
            &conn,
            NewWatchedTracker {
                project_id: project.id.clone(),
                tracker_id: 456,
                tracker_label: "Bugs".to_string(),
                polling_interval: 10,
                analyst_agent_id: analyst.id.clone(),
                developer_agent_id: developer.id.clone(),
                filters: vec![],
            },
        )
        .unwrap();
        (conn, project.id, tracker.id)
    }

    fn insert_ticket_with_timestamps(
        conn: &Connection,
        project_id: &str,
        tracker_id: &str,
        artifact_id: i32,
        status: &str,
        detected_at: &str,
        processed_at: Option<&str>,
    ) {
        conn.execute(
            "INSERT INTO processed_tickets \
             (id, project_id, tracker_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, detected_at, processed_at) \
             VALUES (?1, ?2, ?3, 'tuleap', NULL, ?4, ?5, ?6, ?7, ?8, ?9)",
            params![
                Uuid::new_v4().to_string(),
                project_id,
                tracker_id,
                artifact_id,
                format!("Ticket {}", artifact_id),
                "{}",
                status,
                detected_at,
                processed_at
            ],
        )
        .unwrap();
    }

    #[test]
    fn test_insert_if_new_creates_ticket() {
        let (conn, project_id, tracker_id) = setup();

        let result = ProcessedTicket::insert_if_new(
            &conn,
            &project_id,
            &tracker_id,
            101,
            "Fix login bug",
            "{\"id\": 101}",
        )
        .expect("insert_if_new should succeed");

        assert!(result.is_some());
        let ticket = result.unwrap();
        assert_eq!(ticket.status, "Pending");
        assert_eq!(ticket.project_id, project_id);
        assert_eq!(ticket.source, "tuleap");
        assert!(ticket.source_ref.is_none());
        assert_eq!(ticket.tracker_id.as_deref(), Some(tracker_id.as_str()));
        assert_eq!(ticket.artifact_id, 101);
        assert_eq!(ticket.artifact_title, "Fix login bug");
        assert!(ticket.analyst_report.is_none());
        assert!(ticket.processed_at.is_none());

        let persisted: (String, String, Option<String>, Option<String>) = conn
            .query_row(
                "SELECT project_id, source, source_ref, tracker_id FROM processed_tickets WHERE id = ?1",
                params![ticket.id],
                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
            )
            .expect("processed ticket query should succeed");
        assert_eq!(persisted.0, project_id);
        assert_eq!(persisted.1, "tuleap");
        assert_eq!(persisted.2, None);
        assert_eq!(persisted.3, Some(tracker_id));
    }

    #[test]
    fn test_insert_external_creates_graylog_ticket() {
        let (conn, project_id, _) = setup();

        let ticket = ProcessedTicket::insert_external(
            &conn,
            &project_id,
            "graylog",
            Some("message:abc123"),
            501,
            "Graylog alert",
            "{\"message_id\":\"abc123\"}",
        )
        .expect("insert_external should succeed");

        assert_eq!(ticket.project_id, project_id);
        assert_eq!(ticket.source, "graylog");
        assert_eq!(ticket.source_ref.as_deref(), Some("message:abc123"));
        assert!(ticket.tracker_id.is_none());
        assert_eq!(ticket.status, "Pending");
        assert_eq!(ticket.artifact_id, 501);

        let persisted: (String, String, Option<String>, Option<String>) = conn
            .query_row(
                "SELECT project_id, source, source_ref, tracker_id FROM processed_tickets WHERE id = ?1",
                params![ticket.id],
                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
            )
            .expect("processed ticket query should succeed");
        assert_eq!(persisted.0, project_id);
        assert_eq!(persisted.1, "graylog");
        assert_eq!(persisted.2, Some("message:abc123".to_string()));
        assert_eq!(persisted.3, None);
    }

    #[test]
    fn test_insert_if_new_returns_none_for_duplicate() {
        let (conn, project_id, tracker_id) = setup();

        let first = ProcessedTicket::insert_if_new(
            &conn,
            &project_id,
            &tracker_id,
            202,
            "Crash on startup",
            "{\"id\": 202}",
        )
        .expect("first insert should succeed");
        assert!(first.is_some());

        let second = ProcessedTicket::insert_if_new(
            &conn,
            &project_id,
            &tracker_id,
            202,
            "Crash on startup",
            "{\"id\": 202}",
        )
        .expect("second insert_if_new should succeed");
        assert!(second.is_none());
    }

    #[test]
    fn test_unique_constraint_blocks_manual_duplicate_insert() {
        let (conn, project_id, tracker_id) = setup();
        ProcessedTicket::insert_if_new(
            &conn,
            &project_id,
            &tracker_id,
            909,
            "Duplicate candidate",
            "{}",
        )
        .expect("first insert should succeed");

        let duplicate_insert = conn.execute(
            "INSERT INTO processed_tickets \
             (id, project_id, tracker_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, detected_at) \
             VALUES (?1, ?2, ?3, 'tuleap', NULL, ?4, ?5, ?6, 'Pending', ?7)",
            rusqlite::params![
                Uuid::new_v4().to_string(),
                project_id,
                tracker_id,
                909,
                "Duplicate candidate",
                "{}",
                chrono::Utc::now().to_rfc3339()
            ],
        );
        assert!(duplicate_insert.is_err());

        let duplicates: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM processed_tickets WHERE tracker_id = ?1 AND artifact_id = ?2",
                rusqlite::params![tracker_id, 909],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(duplicates, 1);
    }

    #[test]
    fn test_exists() {
        let (conn, project_id, tracker_id) = setup();

        let before =
            ProcessedTicket::exists(&conn, &tracker_id, 303).expect("exists check should succeed");
        assert!(!before);

        ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 303, "Some ticket", "{}")
            .expect("insert should succeed");

        let after = ProcessedTicket::exists(&conn, &tracker_id, 303)
            .expect("exists check after insert should succeed");
        assert!(after);
    }

    #[test]
    fn test_list_by_tracker() {
        let (conn, project_id, tracker_id) = setup();

        ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "Ticket One", "{}")
            .unwrap();
        ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 2, "Ticket Two", "{}")
            .unwrap();

        let tickets =
            ProcessedTicket::list_by_tracker(&conn, &tracker_id).expect("list should succeed");
        assert_eq!(tickets.len(), 2);
    }

    #[test]
    fn test_get_by_id() {
        let (conn, project_id, tracker_id) = setup();

        let inserted = ProcessedTicket::insert_if_new(
            &conn,
            &project_id,
            &tracker_id,
            404,
            "Not Found Bug",
            "{\"id\": 404}",
        )
        .expect("insert should succeed")
        .expect("should be Some");

        let found =
            ProcessedTicket::get_by_id(&conn, &inserted.id).expect("get_by_id should succeed");

        assert_eq!(found.id, inserted.id);
        assert_eq!(found.artifact_id, 404);
        assert_eq!(found.artifact_title, "Not Found Bug");
        assert_eq!(found.status, "Pending");
    }

    #[test]
    fn test_update_status() {
        let (conn, project_id, tracker_id) = setup();
        let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}")
            .unwrap()
            .unwrap();

        ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing").unwrap();
        let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
        assert_eq!(updated.status, "Analyzing");
    }

    #[test]
    fn test_set_analyst_report() {
        let (conn, project_id, tracker_id) = setup();
        let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}")
            .unwrap()
            .unwrap();

        ProcessedTicket::set_analyst_report(&conn, &ticket.id, "## Report\nAll good.").unwrap();
        let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
        assert_eq!(updated.analyst_report.unwrap(), "## Report\nAll good.");
    }

    #[test]
    fn test_set_developer_report() {
        let (conn, project_id, tracker_id) = setup();
        let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}")
            .unwrap()
            .unwrap();

        ProcessedTicket::set_developer_report(&conn, &ticket.id, "Fixed in main.rs").unwrap();
        let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
        assert_eq!(
            updated.developer_report.as_deref(),
            Some("Fixed in main.rs")
        );
        let processed_at = updated
            .processed_at
            .as_deref()
            .expect("processed_at should be set");
        assert!(chrono::DateTime::parse_from_rfc3339(processed_at).is_ok());
    }

    #[test]
    fn test_set_worktree_info() {
        let (conn, project_id, tracker_id) = setup();
        let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}")
            .unwrap()
            .unwrap();

        ProcessedTicket::set_worktree_info(&conn, &ticket.id, "/tmp/wt", "orchai/1").unwrap();
        let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
        assert_eq!(updated.worktree_path.unwrap(), "/tmp/wt");
        assert_eq!(updated.branch_name.unwrap(), "orchai/1");
    }

    #[test]
    fn test_list_pending() {
        let (conn, project_id, tracker_id) = setup();
        ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}").unwrap();
        ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 2, "T2", "{}").unwrap();

        let pending = ProcessedTicket::list_pending(&conn).unwrap();
        assert_eq!(pending.len(), 2);
        assert_eq!(pending[0].artifact_id, 1);
        assert_eq!(pending[1].artifact_id, 2);

        ProcessedTicket::update_status(&conn, &pending[0].id, "Analyzing").unwrap();
        let pending2 = ProcessedTicket::list_pending(&conn).unwrap();
        assert_eq!(pending2.len(), 1);
        assert_eq!(pending2[0].artifact_id, 2);
    }

    #[test]
    fn test_reset_for_retry_clears_inflight_fields() {
        let (conn, project_id, tracker_id) = setup();
        let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}")
            .unwrap()
            .unwrap();

        ProcessedTicket::update_status(&conn, &ticket.id, "Developing").unwrap();
        ProcessedTicket::set_analyst_report(&conn, &ticket.id, "analysis").unwrap();
        ProcessedTicket::set_developer_report(&conn, &ticket.id, "dev report").unwrap();
        ProcessedTicket::set_worktree_info(&conn, &ticket.id, "/tmp/wt", "orchai/1").unwrap();

        ProcessedTicket::reset_for_retry(&conn, &ticket.id).unwrap();

        let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
        assert_eq!(updated.status, "Pending");
        assert!(updated.analyst_report.is_none());
        assert!(updated.developer_report.is_none());
        assert!(updated.worktree_path.is_none());
        assert!(updated.branch_name.is_none());
        assert!(updated.processed_at.is_none());
    }

    #[test]
    fn test_set_error() {
        let (conn, project_id, tracker_id) = setup();
        let ticket = ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "T1", "{}")
            .unwrap()
            .unwrap();

        ProcessedTicket::set_error(&conn, &ticket.id, "CLI timeout after 600s").unwrap();
        let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
        assert_eq!(updated.status, "Error");
        assert_eq!(
            updated.analyst_report.as_deref(),
            Some("CLI timeout after 600s")
        );
        let processed_at = updated
            .processed_at
            .as_deref()
            .expect("processed_at should be set");
        assert!(chrono::DateTime::parse_from_rfc3339(processed_at).is_ok());
    }

    #[test]
    fn test_get_project_throughput_stats() {
        let (conn, project_id, tracker_id) = setup();
        let now = chrono::Utc::now();
        let pending_detected = (now - chrono::Duration::hours(2)).to_rfc3339();
        let developing_detected = (now - chrono::Duration::hours(3)).to_rfc3339();
        let done_detected = (now - chrono::Duration::hours(4)).to_rfc3339();
        let done_processed = (now - chrono::Duration::hours(2)).to_rfc3339();
        let error_detected = (now - chrono::Duration::hours(7)).to_rfc3339();
        let error_processed = (now - chrono::Duration::hours(3)).to_rfc3339();
        let old_done_detected = (now - chrono::Duration::hours(40)).to_rfc3339();
        let old_done_processed = (now - chrono::Duration::hours(35)).to_rfc3339();
        let cancelled_detected = (now - chrono::Duration::hours(1)).to_rfc3339();

        insert_ticket_with_timestamps(
            &conn,
            &project_id,
            &tracker_id,
            1001,
            "Pending",
            &pending_detected,
            None,
        );
        insert_ticket_with_timestamps(
            &conn,
            &project_id,
            &tracker_id,
            1002,
            "Developing",
            &developing_detected,
            None,
        );
        insert_ticket_with_timestamps(
            &conn,
            &project_id,
            &tracker_id,
            1003,
            "Done",
            &done_detected,
            Some(&done_processed),
        );
        insert_ticket_with_timestamps(
            &conn,
            &project_id,
            &tracker_id,
            1004,
            "Error",
            &error_detected,
            Some(&error_processed),
        );
        insert_ticket_with_timestamps(
            &conn,
            &project_id,
            &tracker_id,
            1005,
            "Done",
            &old_done_detected,
            Some(&old_done_processed),
        );
        insert_ticket_with_timestamps(
            &conn,
            &project_id,
            &tracker_id,
            1006,
            "Cancelled",
            &cancelled_detected,
            None,
        );

        let stats = ProcessedTicket::get_project_throughput_stats(&conn, &project_id).unwrap();

        assert_eq!(stats.backlog_count, 2);
        assert_eq!(stats.done_last_24h, 1);
        assert_eq!(stats.error_last_24h, 1);

        let avg = stats
            .avg_lead_time_seconds
            .expect("avg lead time should be available");
        assert!((avg - 10800.0).abs() < 1.0);
    }

    #[test]
    fn test_get_project_throughput_stats_empty() {
        let (conn, project_id, _) = setup();

        let stats = ProcessedTicket::get_project_throughput_stats(&conn, &project_id).unwrap();
        assert_eq!(stats.backlog_count, 0);
        assert_eq!(stats.done_last_24h, 0);
        assert_eq!(stats.error_last_24h, 0);
        assert!(stats.avg_lead_time_seconds.is_none());
    }

    #[test]
    fn test_list_by_project_includes_external_tickets_without_tracker_join() {
        let (conn, project_id, tracker_id) = setup();

        ProcessedTicket::insert_if_new(&conn, &project_id, &tracker_id, 1, "Tuleap ticket", "{}")
            .unwrap();
        ProcessedTicket::insert_external(
            &conn,
            &project_id,
            "graylog",
            Some("event-1"),
            2,
            "Graylog ticket",
            "{}",
        )
        .unwrap();

        let tickets = ProcessedTicket::list_by_project(&conn, &project_id).unwrap();
        assert_eq!(tickets.len(), 2);
        assert!(tickets.iter().any(|ticket| ticket.source == "graylog"));
        assert!(tickets.iter().any(|ticket| ticket.source == "tuleap"));
    }

    #[test]
    fn test_list_by_project_summary_omits_artifact_payload() {
        let (conn, project_id, tracker_id) = setup();

        ProcessedTicket::insert_if_new(
            &conn,
            &project_id,
            &tracker_id,
            999,
            "Large payload ticket",
            &"x".repeat(10_000),
        )
        .expect("insert should succeed");

        let tickets = ProcessedTicket::list_by_project_summary(&conn, &project_id)
            .expect("summary list should succeed");

        assert!(!tickets.is_empty());
        assert!(tickets.iter().all(|ticket| ticket.artifact_data.is_empty()));
    }
}
