mod dependencies;
pub(crate) mod fragments;
mod hydration;
mod projects;
mod recent_actions;
mod search;
mod sidebar;
mod sorting;
mod sync_history;
mod tasks;
mod types;

#[allow(unused_imports)]
pub(crate) use dependencies::{TaskDependencyItem, TaskDependencySummary, task_dependency_summary};
#[allow(unused_imports)]
pub(crate) use hydration::build_task_list_items;
#[allow(unused_imports)]
pub(crate) use projects::{list_project_items, list_project_items_in_workspace};
#[allow(unused_imports)]
pub(crate) use recent_actions::list_recent_actions_in_workspace;
#[allow(unused_imports)]
pub(crate) use search::{
    SearchMatchedField, TaskSearchPreviewResult, TaskSearchPreviewResultSet, TaskSearchQuery,
    TaskSearchResult, TaskSearchResultSet, search_task_item_set_in_workspace, search_task_items,
    search_task_items_in_workspace, search_task_preview_set_in_workspace,
};
#[allow(unused_imports)]
pub(crate) use sidebar::{
    sidebar_counts, sidebar_counts_for_scope_in_workspace, sidebar_counts_in_workspace,
};
#[allow(unused_imports)]
pub(crate) use sync_history::{SyncHistoryStats, sync_history_stats};
#[allow(unused_imports)]
pub(crate) use tasks::{list_task_items, list_task_items_in_workspace};
#[allow(unused_imports)]
pub(crate) use types::{
    ProjectListItem, RecentActionItem, RecentActionTarget, SidebarCounts, SortDirection,
    TaskDependencyLink, TaskFilters, TaskListItem, TaskNote, TaskQueryMode, TaskSort,
};

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::test_conn;
    use sqlx::SqliteConnection;

    async fn seed_default_project(conn: &mut SqliteConnection) {
        sqlx::query(
            "INSERT INTO projects(id, key, name, prefix, created_at, updated_at)
             VALUES ('PROJECT000000001', 'app', 'app', 'APP', 't', 't')",
        )
        .execute(&mut *conn)
        .await
        .unwrap();
    }

    async fn insert_test_task(
        conn: &mut SqliteConnection,
        id: &str,
        title: &str,
        status: &str,
        priority: &str,
        created_at: &str,
    ) {
        sqlx::query(
            "INSERT INTO tasks(id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
             VALUES (?, ?, '', 'PROJECT000000001', ?, ?, ?, ?, ?)",
        )
        .bind(id)
        .bind(title)
        .bind(status)
        .bind(priority)
        .bind(created_at)
        .bind(created_at)
        .bind(created_at)
        .execute(&mut *conn)
        .await
        .unwrap();
    }

    async fn insert_test_label(conn: &mut SqliteConnection, task_id: &str, label: &str) {
        let workspace_id = crate::workspaces::active_workspace_id();
        sqlx::query(
            "INSERT OR IGNORE INTO labels(workspace_id, name, created_at) VALUES (?, ?, 't')",
        )
        .bind(&workspace_id)
        .bind(label)
        .execute(&mut *conn)
        .await
        .unwrap();

        sqlx::query("INSERT INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)")
            .bind(&workspace_id)
            .bind(task_id)
            .bind(label)
            .execute(&mut *conn)
            .await
            .unwrap();
    }

    async fn insert_test_conflict(conn: &mut SqliteConnection, task_id: &str, resolved: bool) {
        let resolved = if resolved { 1_i64 } else { 0_i64 };
        sqlx::query(
            "INSERT INTO conflicts(workspace_id, task_id, field, base_version, local_value, remote_value,
             local_change_id, remote_change_id, variant_a, variant_b, created_at, resolved)
             VALUES (?, ?, 'title', NULL, 'local', 'remote', NULL, ?, 'a', 'b', ?, ?)",
        )
        .bind(crate::workspaces::active_workspace_id())
        .bind(task_id)
        .bind(crate::ids::new_id())
        .bind(crate::ids::now())
        .bind(resolved)
        .execute(&mut *conn)
        .await
        .unwrap();
    }

    fn listed_titles(items: &[TaskListItem]) -> Vec<&str> {
        items.iter().map(|item| item.task.title.as_str()).collect()
    }

    fn listed_titles_from_search(items: &[TaskSearchResult]) -> Vec<&str> {
        items
            .iter()
            .map(|item| item.item.task.title.as_str())
            .collect()
    }

    struct ActiveWorkspaceGuard {
        previous: crate::workspaces::Workspace,
    }

    impl ActiveWorkspaceGuard {
        fn set(workspace: crate::workspaces::Workspace) -> Self {
            let previous = crate::workspaces::active_workspace();
            crate::workspaces::set_active_workspace(workspace);
            Self { previous }
        }
    }

    impl Drop for ActiveWorkspaceGuard {
        fn drop(&mut self) {
            crate::workspaces::set_active_workspace(self.previous.clone());
        }
    }

    async fn seed_workspace_project(
        conn: &mut SqliteConnection,
        workspace_id: &str,
        key: &str,
        name: &str,
        prefix: &str,
    ) {
        sqlx::query(
            "INSERT INTO projects(id, workspace_id, key, name, prefix, created_at, updated_at)
             VALUES (?, ?, ?, ?, ?, 't', 't')",
        )
        .bind(crate::ids::new_id())
        .bind(workspace_id)
        .bind(key)
        .bind(name)
        .bind(prefix)
        .execute(&mut *conn)
        .await
        .unwrap();
    }

    async fn seed_workspace_label(conn: &mut SqliteConnection, workspace_id: &str, name: &str) {
        sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, 't')")
            .bind(workspace_id)
            .bind(name)
            .execute(&mut *conn)
            .await
            .unwrap();
    }

    #[allow(clippy::too_many_arguments)]
    async fn seed_workspace_task(
        conn: &mut SqliteConnection,
        workspace_id: &str,
        id: &str,
        title: &str,
        project_key: &str,
        status: &str,
        priority: &str,
        created_at: &str,
    ) {
        sqlx::query(
            "INSERT INTO tasks(workspace_id, id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
             VALUES (?, ?, ?, '', (SELECT id FROM projects WHERE workspace_id = ? AND key = ?), ?, ?, ?, ?, ?)",
        )
        .bind(workspace_id)
        .bind(id)
        .bind(title)
        .bind(workspace_id)
        .bind(project_key)
        .bind(status)
        .bind(priority)
        .bind(created_at)
        .bind(created_at)
        .bind(created_at)
        .execute(&mut *conn)
        .await
        .unwrap();
    }

    async fn seed_workspace_task_label(
        conn: &mut SqliteConnection,
        workspace_id: &str,
        task_id: &str,
        label: &str,
    ) {
        sqlx::query("INSERT INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)")
            .bind(workspace_id)
            .bind(task_id)
            .bind(label)
            .execute(&mut *conn)
            .await
            .unwrap();
    }

    async fn seed_workspace_conflict(
        conn: &mut SqliteConnection,
        workspace_id: &str,
        task_id: &str,
    ) {
        sqlx::query(
            "INSERT INTO conflicts(workspace_id, task_id, field, base_version, local_value, remote_value,
             local_change_id, remote_change_id, variant_a, variant_b, created_at, resolved)
             VALUES (?, ?, 'title', NULL, 'local', 'remote', NULL, ?, 'a', 'b', ?, 0)",
        )
        .bind(workspace_id)
        .bind(task_id)
        .bind(crate::ids::new_id())
        .bind(crate::ids::now())
        .execute(&mut *conn)
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn queue_sort_orders_status_then_priority_then_created_at() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        for (id, title, status, priority, created_at) in [
            ("0000000000000001", "done urgent", "done", "urgent", "001"),
            ("0000000000000002", "inbox urgent", "inbox", "urgent", "002"),
            ("0000000000000003", "active low", "active", "low", "003"),
            ("0000000000000004", "todo urgent", "todo", "urgent", "004"),
            (
                "0000000000000005",
                "active urgent",
                "active",
                "urgent",
                "005",
            ),
        ] {
            insert_test_task(&mut conn, id, title, status, priority, created_at).await;
        }

        let items = list_task_items(
            &mut conn,
            TaskFilters {
                hide_done: true,
                ..TaskFilters::default()
            },
            TaskQueryMode::RankedQueue,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();
        assert_eq!(
            listed_titles(&items),
            ["active urgent", "todo urgent", "active low", "inbox urgent"]
        );
    }

    #[tokio::test]
    async fn queue_view_hides_done_and_canceled_tasks() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        for (id, title, status) in [
            ("0000000000000111", "todo task", "todo"),
            ("0000000000000112", "done task", "done"),
            ("0000000000000113", "canceled task", "canceled"),
        ] {
            insert_test_task(&mut conn, id, title, status, "none", "001").await;
        }

        let items = list_task_items(
            &mut conn,
            TaskFilters {
                hide_done: true,
                ..TaskFilters::default()
            },
            TaskQueryMode::RankedQueue,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();
        assert_eq!(listed_titles(&items), ["todo task"]);

        let counts = sidebar_counts(&mut conn).await.unwrap();
        assert_eq!(counts.open, 1);
        assert_eq!(counts.done, 2);
    }

    #[tokio::test]
    async fn priority_sort_orders_priority_then_created_at() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        for (id, title, priority, created_at) in [
            ("0000000000000101", "none old", "none", "001"),
            ("0000000000000102", "urgent", "urgent", "002"),
            ("0000000000000103", "high", "high", "003"),
            ("0000000000000104", "none new", "none", "004"),
        ] {
            insert_test_task(&mut conn, id, title, "todo", priority, created_at).await;
        }

        let items = list_task_items(
            &mut conn,
            TaskFilters::default(),
            TaskQueryMode::Flat,
            TaskSort::Priority,
            SortDirection::Asc,
        )
        .await
        .unwrap();
        assert_eq!(
            listed_titles(&items),
            ["urgent", "high", "none new", "none old"]
        );
    }

    #[tokio::test]
    async fn created_sort_respects_direction() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        for (id, title, created_at) in [
            ("0000000000000201", "first", "001"),
            ("0000000000000202", "second", "002"),
        ] {
            insert_test_task(&mut conn, id, title, "todo", "none", created_at).await;
        }

        let items = list_task_items(
            &mut conn,
            TaskFilters::default(),
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Desc,
        )
        .await
        .unwrap();
        assert_eq!(items[0].task.title, "second");

        let items = list_task_items(
            &mut conn,
            TaskFilters::default(),
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();
        assert_eq!(items[0].task.title, "first");
    }

    #[tokio::test]
    async fn conflicts_only_filter_returns_unresolved_conflicts() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        for (id, title) in [
            ("0000000000000011", "conflicted"),
            ("0000000000000012", "clean"),
        ] {
            insert_test_task(&mut conn, id, title, "todo", "none", "001").await;
        }

        sqlx::query(
            "INSERT INTO conflicts(task_id, field, base_version, local_value, remote_value,
             local_change_id, remote_change_id, variant_a, variant_b, created_at, resolved)
             VALUES ('0000000000000011', 'title', NULL, 'local', 'remote', NULL, ?, 'a', 'b', ?, 0)",
        )
        .bind(crate::ids::new_id())
        .bind(crate::ids::now())
        .execute(&mut *conn)
        .await
        .unwrap();

        let items = list_task_items(
            &mut conn,
            TaskFilters {
                conflicts_only: true,
                ..TaskFilters::default()
            },
            TaskQueryMode::RankedQueue,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();

        assert_eq!(items.len(), 1);
        assert_eq!(items[0].task.title, "conflicted");
        assert!(items[0].has_conflict);
    }

    #[tokio::test]
    async fn list_items_include_labels_and_unresolved_conflict_flags() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "0000000000000301",
            "labeled",
            "todo",
            "none",
            "001",
        )
        .await;
        insert_test_task(
            &mut conn,
            "0000000000000302",
            "resolved",
            "todo",
            "none",
            "002",
        )
        .await;
        insert_test_task(
            &mut conn,
            "0000000000000303",
            "plain",
            "todo",
            "none",
            "003",
        )
        .await;

        insert_test_label(&mut conn, "0000000000000301", "zeta").await;
        insert_test_label(&mut conn, "0000000000000301", "alpha").await;
        insert_test_conflict(&mut conn, "0000000000000301", false).await;
        insert_test_conflict(&mut conn, "0000000000000302", true).await;
        sqlx::query(
            "INSERT INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
             VALUES (?, '0000000000000302', '0000000000000301', '003')",
        )
        .bind(crate::workspaces::active_workspace_id())
        .execute(&mut *conn)
        .await
        .unwrap();

        let items = list_task_items(
            &mut conn,
            TaskFilters::default(),
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();

        assert_eq!(
            items[0].labels,
            vec!["alpha".to_string(), "zeta".to_string()]
        );
        assert!(items[0].has_conflict);
        assert_eq!(items[0].blocks[0].display_ref, "APP-0000000000000302");
        assert_eq!(items[0].blocks[0].title, "resolved");
        assert_eq!(items[1].depends_on[0].display_ref, "APP-0000000000000301");
        assert_eq!(items[1].depends_on[0].title, "labeled");
        assert!(!items[1].has_conflict);
        assert!(items[2].labels.is_empty());
    }

    #[tokio::test]
    async fn list_items_include_description_and_note_metadata() {
        let (_temp, mut conn) = test_conn().await;
        let workspace_id = crate::workspaces::active_workspace_id();
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "0000000000000501",
            "documented",
            "todo",
            "none",
            "001",
        )
        .await;
        sqlx::query("UPDATE tasks SET description = 'details' WHERE workspace_id = ? AND id = ?")
            .bind(&workspace_id)
            .bind("0000000000000501")
            .execute(&mut *conn)
            .await
            .unwrap();
        sqlx::query(
            "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
             VALUES (?, 'note-0501-a', '0000000000000501', 'older', '001', 'change-0501-a'),
                    (?, 'note-0501-b', '0000000000000501', 'newer', '002', 'change-0501-b')",
        )
        .bind(&workspace_id)
        .bind(&workspace_id)
        .execute(&mut *conn)
        .await
        .unwrap();

        let items = list_task_items(
            &mut conn,
            TaskFilters::default(),
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();

        assert_eq!(items[0].task.description, "details");
        assert_eq!(
            items[0]
                .notes
                .iter()
                .map(|note| note.body.as_str())
                .collect::<Vec<_>>(),
            ["newer", "older"]
        );
    }

    #[tokio::test]
    async fn list_items_preserve_display_refs_with_hidden_collisions() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        insert_test_task(
            &mut conn,
            "ABCD000000000001",
            "visible",
            "todo",
            "none",
            "001",
        )
        .await;
        insert_test_task(&mut conn, "ABCD999999999999", "done", "done", "none", "002").await;

        let items = list_task_items(
            &mut conn,
            TaskFilters {
                hide_done: true,
                ..TaskFilters::default()
            },
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();

        assert_eq!(items.len(), 1);
        assert_eq!(items[0].display_ref, "APP-ABCD0");
    }

    #[tokio::test]
    async fn search_filter_matches_titles_and_descriptions() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        for (id, title, description, created_at) in [
            (
                "0000000000000501",
                "Title match needle",
                "plain body",
                "001",
            ),
            (
                "0000000000000502",
                "Body only",
                "body contains needle",
                "002",
            ),
            ("0000000000000503", "Unrelated", "plain body", "003"),
        ] {
            sqlx::query(
                "INSERT INTO tasks(id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
                 VALUES (?, ?, ?, 'PROJECT000000001', 'todo', 'none', ?, ?, ?)",
            )
            .bind(id)
            .bind(title)
            .bind(description)
            .bind(created_at)
            .bind(created_at)
            .bind(created_at)
            .execute(&mut *conn)
            .await
            .unwrap();
        }

        let items = list_task_items(
            &mut conn,
            TaskFilters {
                search: Some("needle".to_string()),
                ..TaskFilters::default()
            },
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();

        assert_eq!(listed_titles(&items), ["Title match needle", "Body only"]);
    }

    #[tokio::test]
    async fn task_search_finds_done_labels_and_notes() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Done release cleanup",
            "done",
            "high",
            "001",
        )
        .await;
        insert_test_task(
            &mut conn,
            "8KQ9A1X4MV2P8D6R",
            "Plain inbox",
            "inbox",
            "none",
            "002",
        )
        .await;
        insert_test_task(
            &mut conn,
            "9KQ9A1X4MV2P8D6R",
            "Add iOS API auth flow",
            "todo",
            "medium",
            "003",
        )
        .await;
        insert_test_label(&mut conn, "8KQ9A1X4MV2P8D6R", "security").await;
        sqlx::query(
            "UPDATE tasks SET description = 'schedule pager rotation handoff' WHERE id = '9KQ9A1X4MV2P8D6R'",
        )
        .execute(&mut *conn)
        .await
        .unwrap();
        sqlx::query(
            "INSERT INTO notes(id, task_id, body, created_at, change_id)
             VALUES ('note-search', '7KQ9A1X4MV2P8D6R', 'contains pager rotation context', '003', 'change-search')",
        )
        .execute(&mut *conn)
        .await
        .unwrap();

        let done = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "release cleanup".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(listed_titles_from_search(&done), ["Done release cleanup"]);
        assert_eq!(done[0].matched_field, SearchMatchedField::Title);

        let label = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "security".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(listed_titles_from_search(&label), ["Plain inbox"]);
        assert_eq!(label[0].matched_field, SearchMatchedField::Label);

        let title_tokens = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "ios auth".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(
            listed_titles_from_search(&title_tokens),
            ["Add iOS API auth flow"]
        );
        assert_eq!(title_tokens[0].matched_field, SearchMatchedField::Title);

        let note = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "pager rotation".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        let note_titles = listed_titles_from_search(&note);
        assert!(note_titles.contains(&"Done release cleanup"));
        assert!(note_titles.contains(&"Add iOS API auth flow"));
        let note_match = note
            .iter()
            .find(|r| r.item.task.title == "Done release cleanup")
            .expect("note match should be in results");
        assert_eq!(note_match.matched_field, SearchMatchedField::Note);
        assert!(
            note_match
                .snippet
                .as_deref()
                .is_some_and(|value| value.contains("pager rotation"))
        );

        let quoted_note = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "\"pager rotation\"".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        let quoted_note_titles = listed_titles_from_search(&quoted_note);
        assert!(quoted_note_titles.contains(&"Done release cleanup"));
        assert!(quoted_note_titles.contains(&"Add iOS API auth flow"));
        let quoted_note_match = quoted_note
            .iter()
            .find(|r| r.item.task.title == "Done release cleanup")
            .expect("note match should be in results");
        assert_eq!(quoted_note_match.matched_field, SearchMatchedField::Note);
        assert!(
            quoted_note_match
                .snippet
                .as_deref()
                .is_some_and(|value| value.contains("pager rotation"))
        );

        let desc_phrase = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "\"pager rotation handoff\"".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(
            listed_titles_from_search(&desc_phrase),
            ["Add iOS API auth flow"]
        );
        assert_eq!(
            desc_phrase[0].matched_field,
            SearchMatchedField::Description
        );
        assert!(
            desc_phrase[0]
                .snippet
                .as_deref()
                .is_some_and(|value| value.contains("pager rotation handoff"))
        );
    }

    fn task_search_fts_match(workspace_id: &str, term: &str) -> String {
        fn phrase(value: &str) -> String {
            format!("\"{}\"", value.replace('"', "\"\""))
        }

        format!("workspace_token:{} {}", phrase(workspace_id), phrase(term))
    }

    async fn task_search_fts_match_count(conn: &mut SqliteConnection, expression: &str) -> i64 {
        sqlx::query_scalar::<_, i64>(
            "SELECT count(*) FROM task_search_fts WHERE task_search_fts MATCH ?",
        )
        .bind(expression)
        .fetch_one(&mut *conn)
        .await
        .unwrap()
    }

    #[tokio::test]
    async fn task_search_document_update_removes_stale_fts_terms() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "FTS maintenance probe",
            "todo",
            "none",
            "001",
        )
        .await;

        let workspace_id = crate::workspaces::active_workspace_id();
        sqlx::query(
            "UPDATE task_search_documents SET notes = 'obsolete pager phrase'
             WHERE workspace_id = ? AND task_id = ?",
        )
        .bind(&workspace_id)
        .bind("7KQ9A1X4MV2P8D6R")
        .execute(&mut *conn)
        .await
        .unwrap();

        let obsolete = task_search_fts_match(&workspace_id, "obsolete pager phrase");
        assert_eq!(task_search_fts_match_count(&mut conn, &obsolete).await, 1);

        sqlx::query(
            "UPDATE task_search_documents SET notes = 'fresh pager phrase'
             WHERE workspace_id = ? AND task_id = ?",
        )
        .bind(&workspace_id)
        .bind("7KQ9A1X4MV2P8D6R")
        .execute(&mut *conn)
        .await
        .unwrap();

        assert_eq!(task_search_fts_match_count(&mut conn, &obsolete).await, 0);

        let fresh = task_search_fts_match(&workspace_id, "fresh pager phrase");
        assert_eq!(task_search_fts_match_count(&mut conn, &fresh).await, 1);
    }

    #[tokio::test]
    async fn task_search_requires_contiguous_text_matches() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Fix dashboard timers resetting spuriously for idle agents",
            "done",
            "medium",
            "001",
        )
        .await;
        insert_test_task(
            &mut conn,
            "8KQ9A1X4MV2P8D6R",
            "Automate multi-shell testing for shell-sensitive test cases",
            "done",
            "medium",
            "002",
        )
        .await;

        let items = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "testing".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();

        assert_eq!(
            listed_titles_from_search(&items),
            ["Automate multi-shell testing for shell-sensitive test cases"]
        );
        assert_eq!(items[0].matched_field, SearchMatchedField::Title);
    }

    #[tokio::test]
    async fn task_search_ranks_refs_and_controls_deleted_results() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Needle in title",
            "todo",
            "none",
            "001",
        )
        .await;
        insert_test_task(
            &mut conn,
            "9KQ9A1X4MV2P8D6R",
            "Deleted needle",
            "todo",
            "none",
            "002",
        )
        .await;
        sqlx::query("UPDATE tasks SET deleted = 1 WHERE id = '9KQ9A1X4MV2P8D6R'")
            .execute(&mut *conn)
            .await
            .unwrap();

        let by_ref = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "7KQ9".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(by_ref[0].item.task.id, "7KQ9A1X4MV2P8D6R");
        assert_eq!(by_ref[0].matched_field, SearchMatchedField::Ref);

        let qualified_ref = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "/APP-7KQ9".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(qualified_ref.len(), 1);
        assert_eq!(qualified_ref[0].item.task.id, "7KQ9A1X4MV2P8D6R");
        assert_eq!(qualified_ref[0].matched_field, SearchMatchedField::Ref);

        let without_deleted = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "deleted needle".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert!(without_deleted.is_empty());

        let with_deleted = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "deleted needle".to_string(),
                include_deleted: true,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(listed_titles_from_search(&with_deleted), ["Deleted needle"]);
        assert!(with_deleted[0].item.task.deleted);

        let single_token_without_deleted = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "needle".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(
            listed_titles_from_search(&single_token_without_deleted),
            ["Needle in title"]
        );
        assert!(
            single_token_without_deleted
                .iter()
                .all(|result| !result.item.task.deleted)
        );

        let deleted_by_ref = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "9KQ9".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(deleted_by_ref.len(), 1);
        assert_eq!(deleted_by_ref[0].item.task.id, "9KQ9A1X4MV2P8D6R");
        assert_eq!(deleted_by_ref[0].matched_field, SearchMatchedField::Ref);
        assert!(deleted_by_ref[0].item.task.deleted);
    }

    #[tokio::test]
    async fn queue_sort_ranks_conflicted_tasks_ahead_of_clean_peers() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        insert_test_task(
            &mut conn,
            "0000000000000401",
            "clean",
            "todo",
            "none",
            "001",
        )
        .await;
        insert_test_task(
            &mut conn,
            "0000000000000402",
            "conflicted",
            "todo",
            "none",
            "002",
        )
        .await;
        insert_test_conflict(&mut conn, "0000000000000402", false).await;

        let items = list_task_items(
            &mut conn,
            TaskFilters::default(),
            TaskQueryMode::RankedQueue,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();

        assert_eq!(listed_titles(&items), ["conflicted", "clean"]);
    }

    #[tokio::test]
    async fn explicit_workspace_read_apis_scope_results() {
        let (_temp, mut conn) = test_conn().await;
        let alpha_id = crate::workspaces::DEFAULT_WORKSPACE_ID.to_string();
        let beta = crate::workspaces::create_workspace(&mut conn, "Beta")
            .await
            .unwrap();
        seed_workspace_project(&mut conn, &alpha_id, "app", "Alpha", "ALP").await;
        seed_workspace_project(&mut conn, &beta.id, "app", "Beta", "BET").await;
        seed_workspace_label(&mut conn, &alpha_id, "shared").await;
        seed_workspace_label(&mut conn, &beta.id, "shared").await;
        seed_workspace_task(
            &mut conn,
            &alpha_id,
            "ALPHA0000000001",
            "alpha task",
            "app",
            "todo",
            "high",
            "001",
        )
        .await;
        seed_workspace_task(
            &mut conn,
            &beta.id,
            "BETA00000000001",
            "beta task",
            "app",
            "done",
            "low",
            "002",
        )
        .await;
        seed_workspace_task_label(&mut conn, &alpha_id, "ALPHA0000000001", "shared").await;
        seed_workspace_task_label(&mut conn, &beta.id, "BETA00000000001", "shared").await;
        seed_workspace_conflict(&mut conn, &alpha_id, "ALPHA0000000001").await;
        let _guard = ActiveWorkspaceGuard::set(beta.clone());

        let alpha_tasks = list_task_items_in_workspace(
            &mut conn,
            &alpha_id,
            TaskFilters {
                project: Some("app".to_string()),
                label: Some("shared".to_string()),
                conflicts_only: true,
                ..TaskFilters::default()
            },
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();
        assert_eq!(listed_titles(&alpha_tasks), ["alpha task"]);
        assert_eq!(alpha_tasks[0].labels, vec!["shared".to_string()]);
        assert!(alpha_tasks[0].has_conflict);

        let beta_tasks = list_task_items_in_workspace(
            &mut conn,
            &beta.id,
            TaskFilters::default(),
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();
        assert_eq!(listed_titles(&beta_tasks), ["beta task"]);

        let alpha_projects = list_project_items_in_workspace(&mut conn, &alpha_id)
            .await
            .unwrap();
        assert_eq!(alpha_projects.len(), 1);
        assert_eq!(alpha_projects[0].key, "app");
        assert_eq!(alpha_projects[0].open_count, 1);

        let beta_projects = list_project_items_in_workspace(&mut conn, &beta.id)
            .await
            .unwrap();
        assert_eq!(beta_projects.len(), 1);
        assert_eq!(beta_projects[0].key, "app");
        assert_eq!(beta_projects[0].open_count, 0);

        let alpha_counts = sidebar_counts_in_workspace(&mut conn, &alpha_id)
            .await
            .unwrap();
        assert_eq!(alpha_counts.open, 1);
        assert_eq!(alpha_counts.todo, 1);
        assert_eq!(alpha_counts.conflicts, 1);
        assert_eq!(alpha_counts.done, 0);

        let beta_counts = sidebar_counts_in_workspace(&mut conn, &beta.id)
            .await
            .unwrap();
        assert_eq!(beta_counts.open, 0);
        assert_eq!(beta_counts.done, 1);
        assert_eq!(beta_counts.conflicts, 0);
    }

    #[tokio::test]
    async fn project_filters_use_project_id_after_key_change() {
        let (_temp, mut conn) = test_conn().await;
        let outcome = crate::projects::create_project_in_workspace(
            &mut conn,
            crate::workspaces::DEFAULT_WORKSPACE_ID,
            "App",
        )
        .await
        .unwrap();
        sqlx::query(
            "INSERT INTO tasks(workspace_id, id, title, description, project_id, status, priority, created_at, updated_at, queue_activity_at)
             VALUES (?, 'ABCDEF0000000000', 'kept', '', ?, 'todo', 'none', 't', 't', 't')",
        )
        .bind(crate::workspaces::DEFAULT_WORKSPACE_ID)
        .bind(&outcome.project.id)
        .execute(&mut *conn)
        .await
        .unwrap();
        sqlx::query("UPDATE projects SET key = 'renamed-app' WHERE id = ?")
            .bind(&outcome.project.id)
            .execute(&mut *conn)
            .await
            .unwrap();

        let items = list_task_items(
            &mut conn,
            TaskFilters {
                project: Some("renamed-app".to_string()),
                ..TaskFilters::default()
            },
            TaskQueryMode::Flat,
            TaskSort::Updated,
            SortDirection::Desc,
        )
        .await
        .unwrap();

        assert_eq!(items.len(), 1);
        assert_eq!(items[0].task.project_key, "renamed-app");
    }

    #[tokio::test]
    async fn active_workspace_wrappers_delegate_to_active_workspace() {
        let (_temp, mut conn) = test_conn().await;
        let alpha_id = crate::workspaces::DEFAULT_WORKSPACE_ID.to_string();
        let beta = crate::workspaces::create_workspace(&mut conn, "Beta")
            .await
            .unwrap();
        seed_workspace_project(&mut conn, &alpha_id, "alpha", "Alpha", "ALP").await;
        seed_workspace_project(&mut conn, &beta.id, "beta", "Beta", "BET").await;
        seed_workspace_task(
            &mut conn,
            &alpha_id,
            "ALPHA0000000001",
            "alpha task",
            "alpha",
            "todo",
            "none",
            "001",
        )
        .await;
        seed_workspace_task(
            &mut conn,
            &beta.id,
            "BETA00000000001",
            "beta task",
            "beta",
            "todo",
            "none",
            "002",
        )
        .await;
        let _guard = ActiveWorkspaceGuard::set(beta.clone());

        let wrapper_tasks = list_task_items(
            &mut conn,
            TaskFilters::default(),
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();
        let explicit_tasks = list_task_items_in_workspace(
            &mut conn,
            &beta.id,
            TaskFilters::default(),
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();
        assert_eq!(
            listed_titles(&wrapper_tasks),
            listed_titles(&explicit_tasks)
        );
        assert_eq!(listed_titles(&wrapper_tasks), ["beta task"]);
        assert_eq!(list_project_items(&mut conn).await.unwrap()[0].key, "beta");
        assert_eq!(sidebar_counts(&mut conn).await.unwrap().open, 1);
    }

    #[tokio::test]
    async fn task_search_accepts_unsafe_query_parser_input() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Pager rotation cleanup",
            "todo",
            "none",
            "001",
        )
        .await;

        for input in [
            "",
            "\"",
            "\"\"",
            "(",
            ")",
            "a*b",
            "\"(",
            "AND OR NOT",
            ":",
            "/",
            "-",
            "a:b",
            "\"unfinished",
            "x OR y",
        ] {
            search_task_items(
                &mut conn,
                TaskSearchQuery {
                    text: input.to_string(),
                    include_deleted: false,
                    limit: 5,
                },
            )
            .await
            .expect("search input must parse and search safely");
        }
    }

    #[tokio::test]
    async fn task_search_finds_rows_backfilled_into_fts_index() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Backfilled migration text",
            "todo",
            "none",
            "001",
        )
        .await;

        let items = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "backfilled".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();

        assert_eq!(
            listed_titles_from_search(&items),
            ["Backfilled migration text"]
        );
    }

    #[tokio::test]
    async fn task_search_uses_fts_candidates_for_common_text() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Needle visible in fts",
            "todo",
            "none",
            "001",
        )
        .await;

        // Use a multi-word query so it is NOT treated as a ref query by the parser.
        // Multi-word queries route through the FTS path.
        let before = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "visible in".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(
            listed_titles_from_search(&before),
            ["Needle visible in fts"]
        );

        // Delete the FTS entry directly. This removes the FTS index entry
        // while keeping the content table and tasks row intact.
        sqlx::query(
            "DELETE FROM task_search_fts WHERE rowid = (SELECT doc_id FROM task_search_documents WHERE task_id = '7KQ9A1X4MV2P8D6R')",
        )
        .execute(&mut *conn)
        .await
        .unwrap();

        // After FTS entry is deleted, search should return nothing because
        // the FTS path is used for multi-word text queries.
        let after = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "visible in".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert!(after.is_empty());
    }

    #[tokio::test]
    async fn task_search_fts_updates_when_search_text_changes() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Original title",
            "todo",
            "none",
            "001",
        )
        .await;

        sqlx::query(
            "UPDATE tasks SET title = 'Retitled orchard', description = 'body lagoon' WHERE id = '7KQ9A1X4MV2P8D6R'",
        )
        .execute(&mut *conn)
        .await
        .unwrap();
        assert_eq!(
            listed_titles_from_search(
                &search_task_items(
                    &mut conn,
                    TaskSearchQuery {
                        text: "orchard".to_string(),
                        include_deleted: false,
                        limit: 10,
                    },
                )
                .await
                .unwrap()
            ),
            ["Retitled orchard"]
        );
        assert_eq!(
            listed_titles_from_search(
                &search_task_items(
                    &mut conn,
                    TaskSearchQuery {
                        text: "lagoon".to_string(),
                        include_deleted: false,
                        limit: 10,
                    },
                )
                .await
                .unwrap()
            ),
            ["Retitled orchard"]
        );

        insert_test_label(&mut conn, "7KQ9A1X4MV2P8D6R", "garden").await;
        assert_eq!(
            listed_titles_from_search(
                &search_task_items(
                    &mut conn,
                    TaskSearchQuery {
                        text: "garden".to_string(),
                        include_deleted: false,
                        limit: 10,
                    },
                )
                .await
                .unwrap()
            ),
            ["Retitled orchard"]
        );
        sqlx::query(
            "DELETE FROM task_labels WHERE task_id = '7KQ9A1X4MV2P8D6R' AND label = 'garden'",
        )
        .execute(&mut *conn)
        .await
        .unwrap();
        assert!(
            search_task_items(
                &mut conn,
                TaskSearchQuery {
                    text: "garden".to_string(),
                    include_deleted: false,
                    limit: 10,
                },
            )
            .await
            .unwrap()
            .is_empty()
        );

        sqlx::query(
            "INSERT INTO notes(id, task_id, body, created_at, change_id) VALUES ('note-fts', '7KQ9A1X4MV2P8D6R', 'harbor memo', '002', 'change-fts')",
        )
        .execute(&mut *conn)
        .await
        .unwrap();
        assert_eq!(
            listed_titles_from_search(
                &search_task_items(
                    &mut conn,
                    TaskSearchQuery {
                        text: "harbor".to_string(),
                        include_deleted: false,
                        limit: 10,
                    },
                )
                .await
                .unwrap()
            ),
            ["Retitled orchard"]
        );
        sqlx::query("UPDATE notes SET body = 'summit memo' WHERE id = 'note-fts'")
            .execute(&mut *conn)
            .await
            .unwrap();
        assert!(
            search_task_items(
                &mut conn,
                TaskSearchQuery {
                    text: "harbor".to_string(),
                    include_deleted: false,
                    limit: 10,
                },
            )
            .await
            .unwrap()
            .is_empty()
        );
        assert_eq!(
            listed_titles_from_search(
                &search_task_items(
                    &mut conn,
                    TaskSearchQuery {
                        text: "summit".to_string(),
                        include_deleted: false,
                        limit: 10,
                    },
                )
                .await
                .unwrap()
            ),
            ["Retitled orchard"]
        );

        sqlx::query("UPDATE projects SET name = 'Beacon Project' WHERE key = 'app'")
            .execute(&mut *conn)
            .await
            .unwrap();
        assert_eq!(
            listed_titles_from_search(
                &search_task_items(
                    &mut conn,
                    TaskSearchQuery {
                        text: "beacon".to_string(),
                        include_deleted: false,
                        limit: 10,
                    },
                )
                .await
                .unwrap()
            ),
            ["Retitled orchard"]
        );
    }

    #[tokio::test]
    async fn task_search_ref_lane_keeps_glyph_normalization_out_of_text_lanes() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "looking glass",
            "todo",
            "none",
            "001",
        )
        .await;

        let text = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "100king".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert!(text.is_empty());
    }

    #[tokio::test]
    async fn task_search_ref_lane_handles_durable_ids_and_punctuation() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Needle in title",
            "todo",
            "none",
            "001",
        )
        .await;
        insert_test_task(
            &mut conn,
            "9KQ9A1X4MV2P8D6R",
            "Deleted needle",
            "todo",
            "none",
            "002",
        )
        .await;
        sqlx::query("UPDATE tasks SET deleted = 1 WHERE id = '9KQ9A1X4MV2P8D6R'")
            .execute(&mut *conn)
            .await
            .unwrap();

        let durable = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "7KQ9A1X4MV2P8D6R".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(durable[0].item.task.id, "7KQ9A1X4MV2P8D6R");
        assert_eq!(durable[0].matched_field, SearchMatchedField::Ref);

        let punctuated = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "/APP.7KQ9".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(punctuated[0].item.task.id, "7KQ9A1X4MV2P8D6R");
        assert_eq!(punctuated[0].matched_field, SearchMatchedField::Ref);

        let spaced = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "APP 7KQ9".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(spaced[0].item.task.id, "7KQ9A1X4MV2P8D6R");
        assert_eq!(spaced[0].matched_field, SearchMatchedField::Ref);

        let wrong_prefix = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "/WRONG-7KQ9".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert!(wrong_prefix.is_empty());

        let short_deleted = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "9KQ".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert!(short_deleted.is_empty());

        let query_also_in_title = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "needle".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(
            query_also_in_title[0].matched_field,
            SearchMatchedField::Title
        );
    }

    #[tokio::test]
    async fn task_search_reranks_title_ref_label_note_and_metadata_evidence() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Orchard release",
            "todo",
            "medium",
            "001",
        )
        .await;
        insert_test_task(
            &mut conn,
            "8KQ9A1X4MV2P8D6R",
            "Label and note",
            "todo",
            "none",
            "002",
        )
        .await;
        insert_test_task(
            &mut conn,
            "9KQ9A1X4MV2P8D6R",
            "Project metadata",
            "todo",
            "urgent",
            "003",
        )
        .await;
        insert_test_task(
            &mut conn,
            "AKQ9A1X4MV2P8D6R",
            "Note only",
            "todo",
            "none",
            "004",
        )
        .await;
        insert_test_label(&mut conn, "8KQ9A1X4MV2P8D6R", "orchard").await;
        sqlx::query(
            "INSERT INTO notes(id, task_id, body, created_at, change_id)
             VALUES ('note-rerank-a', '8KQ9A1X4MV2P8D6R', 'orchard detail', '002', 'change-rerank-a'),
                    ('note-rerank-b', 'AKQ9A1X4MV2P8D6R', 'orchard detail', '004', 'change-rerank-b')",
        )
        .execute(&mut *conn)
        .await
        .unwrap();
        sqlx::query(
            "INSERT INTO projects(id, key, name, prefix, created_at, updated_at)
             VALUES ('PROJECT000000002', 'meta', 'Orchard Metadata', 'MET', 't', 't')",
        )
        .execute(&mut *conn)
        .await
        .unwrap();
        sqlx::query(
            "UPDATE tasks SET project_id = 'PROJECT000000002' WHERE id = '9KQ9A1X4MV2P8D6R'",
        )
        .execute(&mut *conn)
        .await
        .unwrap();

        let items = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "orchard".to_string(),
                include_deleted: true,
                limit: 10,
            },
        )
        .await
        .unwrap();

        let position = |title: &str| {
            items
                .iter()
                .position(|item| item.item.task.title == title)
                .unwrap()
        };
        let title = position("Orchard release");
        let label = position("Label and note");
        let project = position("Project metadata");
        let note = position("Note only");

        assert!(title < label);
        assert!(label < project);
        assert!(project < note);
        assert_eq!(items[title].matched_field, SearchMatchedField::Title);
        assert_eq!(items[label].matched_field, SearchMatchedField::Label);
        assert_eq!(items[project].matched_field, SearchMatchedField::Project);
        assert_eq!(items[note].matched_field, SearchMatchedField::Note);

        let by_ref = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "7KQ9".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();
        assert_eq!(by_ref[0].item.task.id, "7KQ9A1X4MV2P8D6R");
        assert_eq!(by_ref[0].matched_field, SearchMatchedField::Ref);
    }

    #[tokio::test]
    async fn task_search_reranks_multi_token_proximity_and_field_count() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;
        insert_test_task(
            &mut conn,
            "7KQ9A1X4MV2P8D6R",
            "Auth flow",
            "todo",
            "none",
            "001",
        )
        .await;
        insert_test_task(
            &mut conn,
            "8KQ9A1X4MV2P8D6R",
            "Metadata bridge",
            "todo",
            "none",
            "002",
        )
        .await;
        insert_test_task(
            &mut conn,
            "9KQ9A1X4MV2P8D6R",
            "Note spread",
            "todo",
            "urgent",
            "003",
        )
        .await;
        insert_test_label(&mut conn, "8KQ9A1X4MV2P8D6R", "auth").await;
        insert_test_label(&mut conn, "8KQ9A1X4MV2P8D6R", "flow").await;
        sqlx::query(
            "INSERT INTO notes(id, task_id, body, created_at, change_id)
             VALUES ('note-token-a', '9KQ9A1X4MV2P8D6R', 'auth text with several unrelated words before flow', '003', 'change-token-a')",
        )
        .execute(&mut *conn)
        .await
        .unwrap();

        let items = search_task_items(
            &mut conn,
            TaskSearchQuery {
                text: "auth flow".to_string(),
                include_deleted: false,
                limit: 10,
            },
        )
        .await
        .unwrap();

        assert_eq!(
            listed_titles_from_search(&items),
            ["Auth flow", "Metadata bridge", "Note spread"]
        );
        assert_eq!(items[0].matched_field, SearchMatchedField::Title);
        assert_eq!(items[1].matched_field, SearchMatchedField::Label);
        assert_eq!(items[2].matched_field, SearchMatchedField::Note);
    }

    #[tokio::test]
    async fn epics_filter_lists_explicit_epics_with_children() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        let workspace_id = crate::workspaces::active_workspace_id();

        // Parent task - open
        insert_test_task(
            &mut conn,
            "EPIC00000000001",
            "epic parent",
            "todo",
            "high",
            "001",
        )
        .await;
        // Child task - open
        insert_test_task(
            &mut conn,
            "EPIC00000000002",
            "open child",
            "active",
            "none",
            "002",
        )
        .await;
        // Another parent - done (should NOT match the filter)
        insert_test_task(
            &mut conn,
            "EPIC00000000003",
            "done parent",
            "done",
            "none",
            "003",
        )
        .await;
        // A parent with a done child (should NOT match the filter)
        insert_test_task(
            &mut conn,
            "EPIC00000000004",
            "parent with done child",
            "todo",
            "none",
            "004",
        )
        .await;
        insert_test_task(
            &mut conn,
            "EPIC00000000005",
            "done child",
            "done",
            "none",
            "005",
        )
        .await;
        // An open task with no dependents (should NOT match)
        insert_test_task(
            &mut conn,
            "EPIC00000000006",
            "lonely task",
            "todo",
            "none",
            "006",
        )
        .await;

        sqlx::query(
            "UPDATE tasks SET is_epic = 1 WHERE id IN ('EPIC00000000001', 'EPIC00000000004')",
        )
        .execute(&mut *conn)
        .await
        .unwrap();

        // epic parent -> open child
        sqlx::query(
            "INSERT INTO task_epic_links(workspace_id, child_task_id, epic_task_id, created_at)
             VALUES (?, 'EPIC00000000002', 'EPIC00000000001', '002')",
        )
        .bind(&workspace_id)
        .execute(&mut *conn)
        .await
        .unwrap();

        // done parent -> (no-op, parent is done)
        sqlx::query(
            "INSERT INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
             VALUES (?, 'EPIC00000000001', 'EPIC00000000003', '003')",
        )
        .bind(&workspace_id)
        .execute(&mut *conn)
        .await
        .unwrap();

        // parent with done child -> done child
        sqlx::query(
            "INSERT INTO task_epic_links(workspace_id, child_task_id, epic_task_id, created_at)
             VALUES (?, 'EPIC00000000005', 'EPIC00000000004', '005')",
        )
        .bind(&workspace_id)
        .execute(&mut *conn)
        .await
        .unwrap();

        let items = list_task_items(
            &mut conn,
            TaskFilters {
                epics_only: true,
                hide_done: true,
                ..TaskFilters::default()
            },
            TaskQueryMode::Flat,
            TaskSort::Created,
            SortDirection::Asc,
        )
        .await
        .unwrap();

        assert_eq!(
            listed_titles(&items),
            ["epic parent", "parent with done child"]
        );
        assert_eq!(items[0].epic_children.len(), 1);
        assert_eq!(items[0].epic_children[0].task_id, "EPIC00000000002");
        assert_eq!(items[0].epic_children[0].display_ref, "APP-EPIC00000000002");
        assert!(items[0].epic_children[0].unresolved);
    }

    #[tokio::test]
    async fn sidebar_counts_include_epics_count() {
        let (_temp, mut conn) = test_conn().await;
        seed_default_project(&mut conn).await;

        insert_test_task(&mut conn, "EPCS00000000001", "epic", "todo", "none", "001").await;
        insert_test_task(
            &mut conn,
            "EPCS00000000002",
            "child",
            "active",
            "none",
            "002",
        )
        .await;
        insert_test_task(
            &mut conn,
            "EPCS00000000003",
            "not epic",
            "todo",
            "none",
            "003",
        )
        .await;

        sqlx::query("UPDATE tasks SET is_epic = 1 WHERE id = 'EPCS00000000001'")
            .execute(&mut *conn)
            .await
            .unwrap();

        let counts = sidebar_counts(&mut conn).await.unwrap();

        assert_eq!(counts.open, 3);
        assert_eq!(counts.epics, 1);
    }
}
