use std::path::Path;

use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};

use crate::error::{Entity, Error, OpenOp, Result};

/// Initialise the SQLite database with connection pool and run migrations.
pub async fn init(db_path: &Path) -> Result<SqlitePool> {
    if let Some(parent) = db_path.parent() {
        std::fs::create_dir_all(parent).map_err(|source| Error::Filesystem {
            path: parent.to_path_buf(),
            source,
        })?;
    }

    let options = SqliteConnectOptions::new()
        .filename(db_path)
        .create_if_missing(true);

    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect_with(options)
        .await
        .map_err(|source| Error::DatabaseOpen {
            operation: OpenOp::Connect,
            source,
        })?;

    sqlx::query("PRAGMA foreign_keys = ON")
        .execute(&pool)
        .await
        .map_err(|source| Error::DatabaseOpen {
            operation: OpenOp::ForeignKeysPragma,
            source,
        })?;

    run_migrations(&pool).await?;

    Ok(pool)
}

/// Open the SQLite database in read-only mode without running migrations.
///
/// Used by `lumotia-mcp` so the MCP server cannot write to the user's database
/// regardless of which tools the dispatcher exposes — `read_only(true)` makes
/// the constraint structural rather than relying on the request handler being
/// well-behaved. Fails cleanly if the DB doesn't exist (no `create_if_missing`).
pub async fn init_readonly(db_path: &Path) -> Result<SqlitePool> {
    let options = SqliteConnectOptions::new()
        .filename(db_path)
        .create_if_missing(false)
        .read_only(true);

    SqlitePoolOptions::new()
        .max_connections(2)
        .connect_with(options)
        .await
        .map_err(|source| Error::DatabaseOpen {
            operation: OpenOp::ReadOnlyConnect,
            source,
        })
}

/// Run schema migrations via the versioned migration system.
async fn run_migrations(pool: &SqlitePool) -> Result<()> {
    crate::migrations::run_migrations(pool).await
}

// --- Transcript CRUD ---

/// Parameters for inserting a transcript with full provenance.
pub struct InsertTranscriptParams<'a> {
    pub id: &'a str,
    pub text: &'a str,
    pub source: &'a str,
    pub profile_id: &'a str,
    pub title: Option<&'a str>,
    pub audio_path: Option<&'a str>,
    pub duration: f64,
    pub engine: Option<&'a str>,
    pub model_id: Option<&'a str>,
    pub inference_ms: Option<i64>,
    pub sample_rate: Option<i64>,
    pub audio_channels: Option<i64>,
    pub format_mode: Option<&'a str>,
    pub remove_fillers: bool,
    pub british_english: bool,
    pub anti_hallucination: bool,
}

pub async fn insert_transcript(
    pool: &SqlitePool,
    params: &InsertTranscriptParams<'_>,
) -> Result<()> {
    if !profile_exists(pool, params.profile_id).await? {
        return Err(Error::InvalidReference {
            entity: Entity::Profile,
            reason: format!("unknown profile id '{}'", params.profile_id).into(),
        });
    }

    sqlx::query(
        "INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
    )
    .bind(params.id)
    .bind(params.text)
    .bind(params.source)
    .bind(params.profile_id)
    .bind(params.title)
    .bind(params.audio_path)
    .bind(params.duration)
    .bind(params.engine)
    .bind(params.model_id)
    .bind(params.inference_ms)
    .bind(params.sample_rate)
    .bind(params.audio_channels)
    .bind(params.format_mode)
    .bind(params.remove_fillers)
    .bind(params.british_english)
    .bind(params.anti_hallucination)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "insert_transcript".into(),
        source,
    })?;
    Ok(())
}

/// Fetch a transcript by id, EXCLUDING soft-deleted (deleted_at IS NOT NULL)
/// rows. Soft-deleted rows are still in the table — they are scoped to the
/// trash view via `list_trashed_transcripts` / `restore_transcript` — but the
/// regular read path treats them as gone. Rev-2 (atomiser 2026-05-12).
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
    let row = sqlx::query(
        "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts WHERE id = ? AND deleted_at IS NULL",
    )
    .bind(id)
    .fetch_optional(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "get_transcript".into(),
        source,
    })?;

    Ok(row.map(|r| transcript_row_from(&r)))
}

pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> {
    list_transcripts_paged(pool, limit, 0).await
}

/// Paginated transcript list. `limit` is rows-per-page, `offset` is rows to skip.
/// Used by HistoryPage to load 50 at a time and append more on scroll.
pub async fn list_transcripts_paged(
    pool: &SqlitePool,
    limit: i64,
    offset: i64,
) -> Result<Vec<TranscriptRow>> {
    // `deleted_at IS NULL` filter is the Rev-2 soft-delete contract: rows
    // in the trash are kept in the table for restore, but the regular list
    // path treats them as gone.
    let rows = sqlx::query(
        "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?",
    )
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_transcripts".into(),
        source,
    })?;

    Ok(rows.iter().map(transcript_row_from).collect())
}

/// Total count of transcripts. Useful for displaying "showing 50 of 312" in the UI.
/// Excludes soft-deleted (deleted_at IS NOT NULL) rows — they only count
/// toward the trash view.
pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE deleted_at IS NULL")
        .fetch_one(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "count_transcripts".into(),
            source,
        })?;
    Ok(n)
}

/// Update mutable transcript fields. Currently `text` and `title`. Other
/// columns (engine, model, audio path) are immutable post-creation. Returns
/// the number of rows updated (0 if id not found).
///
/// This is the function HistoryPage's rename flow needed; previously the
/// rename was a UI-only state change with a TODO never wired up.
/// (architecture-review.md §13)
pub async fn update_transcript(
    pool: &SqlitePool,
    id: &str,
    text: Option<&str>,
    title: Option<&str>,
) -> Result<u64> {
    // Build the SET clause dynamically so we only update the fields the
    // caller actually changed. Two fields max so we can keep this simple
    // without a query builder.
    match (text, title) {
        (Some(t), Some(ttl)) => {
            let res = sqlx::query("UPDATE transcripts SET text = ?, title = ? WHERE id = ?")
                .bind(t)
                .bind(ttl)
                .bind(id)
                .execute(pool)
                .await
                .map_err(|source| Error::Query {
                    operation: "update_transcript".into(),
                    source,
                })?;
            Ok(res.rows_affected())
        }
        (Some(t), None) => {
            let res = sqlx::query("UPDATE transcripts SET text = ? WHERE id = ?")
                .bind(t)
                .bind(id)
                .execute(pool)
                .await
                .map_err(|source| Error::Query {
                    operation: "update_transcript".into(),
                    source,
                })?;
            Ok(res.rows_affected())
        }
        (None, Some(ttl)) => {
            let res = sqlx::query("UPDATE transcripts SET title = ? WHERE id = ?")
                .bind(ttl)
                .bind(id)
                .execute(pool)
                .await
                .map_err(|source| Error::Query {
                    operation: "update_transcript".into(),
                    source,
                })?;
            Ok(res.rows_affected())
        }
        (None, None) => Ok(0),
    }
}

/// Patch-style update for the Task 2.5 transcripts_meta columns (starred,
/// manual_tags, template, language, segments_json). Follows the same
/// COALESCE pattern as `update_task`: each `Option::Some` overwrites,
/// each `None` preserves the existing column value. Returns the refreshed
/// row or errors if `id` does not exist after the UPDATE.
///
/// Keeping this separate from `update_transcript` (text / title) means the
/// existing command surface stays stable — the viewer and history store
/// keep calling `update_transcript` for content edits, and call this for
/// UI-metadata persistence.
#[allow(clippy::too_many_arguments)] // Each Option maps to one COALESCE.
pub async fn update_transcript_meta(
    pool: &SqlitePool,
    id: &str,
    starred: Option<bool>,
    manual_tags: Option<&str>,
    template: Option<&str>,
    language: Option<&str>,
    segments_json: Option<&str>,
    llm_tags: Option<&str>,
) -> Result<TranscriptRow> {
    sqlx::query(
        "UPDATE transcripts SET \
         starred = COALESCE(?, starred), \
         manual_tags = COALESCE(?, manual_tags), \
         template = COALESCE(?, template), \
         language = COALESCE(?, language), \
         segments_json = COALESCE(?, segments_json), \
         llm_tags = COALESCE(?, llm_tags) \
         WHERE id = ?",
    )
    .bind(starred.map(|b| b as i64))
    .bind(manual_tags)
    .bind(template)
    .bind(language)
    .bind(segments_json)
    .bind(llm_tags)
    .bind(id)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "update_transcript_meta".into(),
        source,
    })?;

    get_transcript(pool, id)
        .await?
        .ok_or_else(|| Error::NotFound {
            entity: Entity::Transcript,
            key: id.to_string(),
        })
}

/// Soft-delete a transcript and best-effort remove its audio file.
///
/// Rev-2 / Rev-3 reversibility kill (atomiser 2026-05-12). The previous
/// implementation issued a hard `DELETE FROM transcripts WHERE id = ?`
/// and never touched the WAV file at `audio_path`. Two failure modes
/// fanned out from that:
///
///   * Rev-2: a single click on the History "Clear All" / "Confirm"
///     button immediately erased months of dictation with no trash,
///     no export, no undo.
///   * Rev-3: even single-row deletes left the audio file on disk;
///     the recordings dir grew monotonically.
///
/// The new contract:
///
///   1. Capture the existing row (so we still have `audio_path` even if
///      the soft-delete is observed by a parallel reader before we get
///      here).
///   2. UPDATE deleted_at = datetime('now'). FTS triggers fire on UPDATE
///      and re-index the row, but `search_transcripts` / `list_transcripts`
///      filter `deleted_at IS NULL`, so the trash rows don't surface.
///   3. Best-effort `tokio::fs::remove_file(audio_path)`. A failure
///      (file already gone, permission denied) is logged but does NOT
///      propagate — the DB soft-delete has already succeeded, and
///      `purge_deleted_transcripts` will retry the removal later via
///      its own audio_path lookup before hard-deleting the row.
///
/// Returns `Ok(())` even when no row matched the id (idempotent).
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
    // Capture audio_path FIRST. We deliberately bypass `get_transcript`
    // (which filters deleted_at IS NULL) so a double-delete still finds
    // the row and the second call is a no-op cleanup rather than an
    // error.
    let audio_path: Option<String> =
        sqlx::query_scalar("SELECT audio_path FROM transcripts WHERE id = ?")
            .bind(id)
            .fetch_optional(pool)
            .await
            .map_err(|source| Error::Query {
                operation: "delete_transcript".into(),
                source,
            })?
            .flatten();

    let res = sqlx::query(
        "UPDATE transcripts SET deleted_at = datetime('now') \
         WHERE id = ? AND deleted_at IS NULL",
    )
    .bind(id)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "delete_transcript".into(),
        source,
    })?;

    // Best-effort audio cleanup. Only attempt removal when the
    // soft-delete actually flipped a row from live -> trashed; on a
    // repeat soft-delete the file is already gone (or never existed)
    // and there's nothing to do.
    if res.rows_affected() > 0 {
        if let Some(path) = audio_path.as_deref() {
            if let Err(err) = tokio::fs::remove_file(path).await {
                if err.kind() != std::io::ErrorKind::NotFound {
                    tracing::warn!(
                        target: "lumotia_storage",
                        "delete_transcript: failed to remove audio file at {path}: {err}"
                    );
                }
            }
        }
    }

    Ok(())
}

/// Hard-delete soft-deleted rows older than `older_than_days`. Intended to
/// run once per startup (or on a daily cron). Returns the number of rows
/// removed.
///
/// FK cascades: `segments` ON DELETE CASCADE (v1) fires; tasks with
/// `source_transcript_id` ON DELETE SET NULL (v8) is preserved.
///
/// Audio files are also best-effort removed here in case the original
/// soft-delete failed at the filesystem layer (Rev-3 belt-and-braces).
///
/// **Atomicity (Phase B.4 audit fix 2026-05-14):** the prior form was a
/// two-statement SELECT-then-DELETE-WHERE-id-IN sequence. If a row was
/// restored between the SELECT and the DELETE (`restore_transcript`
/// clearing `deleted_at`), the DELETE still hard-deleted the now-live
/// row and removed its audio file — bypassing the soft-delete safety
/// contract Rev-2 was added to enforce. We now use a single
/// `DELETE … RETURNING` so the row filter is re-evaluated atomically at
/// DELETE time and the returned `audio_path`s are guaranteed to belong
/// to rows that this call actually hard-deleted. This also removes the
/// chunking concern (no `IN(…)` clause means no SQLITE_MAX_VARIABLE_NUMBER
/// ceiling).
pub async fn purge_deleted_transcripts(pool: &SqlitePool, older_than_days: i64) -> Result<u64> {
    let rows = sqlx::query(
        "DELETE FROM transcripts \
         WHERE deleted_at IS NOT NULL \
           AND deleted_at < datetime('now', ?) \
         RETURNING audio_path",
    )
    .bind(format!("-{older_than_days} days"))
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "purge_deleted_transcripts".into(),
        source,
    })?;

    if rows.is_empty() {
        return Ok(0);
    }

    let mut audio_paths: Vec<String> = Vec::new();
    for row in &rows {
        let audio: Option<String> = row.get("audio_path");
        if let Some(p) = audio {
            audio_paths.push(p);
        }
    }

    // Best-effort audio cleanup. NotFound is the expected case for rows
    // whose audio was already removed at soft-delete time.
    for path in &audio_paths {
        if let Err(err) = tokio::fs::remove_file(path).await {
            if err.kind() != std::io::ErrorKind::NotFound {
                tracing::warn!(
                    target: "lumotia_storage",
                    "purge_deleted_transcripts: failed to remove audio file at {path}: {err}"
                );
            }
        }
    }

    Ok(rows.len() as u64)
}

/// List soft-deleted transcripts (the "trash" view), most-recently-deleted
/// first. The contract mirrors `list_transcripts_paged` but filters
/// `deleted_at IS NOT NULL` so the regular history view and the trash view
/// are exact complements.
pub async fn list_trashed_transcripts(
    pool: &SqlitePool,
    limit: i64,
    offset: i64,
) -> Result<Vec<TranscriptRow>> {
    let rows = sqlx::query(
        "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags \
         FROM transcripts \
         WHERE deleted_at IS NOT NULL \
         ORDER BY deleted_at DESC LIMIT ? OFFSET ?",
    )
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_trashed_transcripts".into(),
        source,
    })?;

    Ok(rows.iter().map(transcript_row_from).collect())
}

/// Restore a soft-deleted transcript by clearing `deleted_at`. Idempotent:
/// restoring a live row is a no-op. Note that the audio file at
/// `audio_path` may already have been removed by `delete_transcript`'s
/// best-effort filesystem cleanup; restoring the row recovers the text and
/// metadata but the audio may still be missing.
pub async fn restore_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
    sqlx::query("UPDATE transcripts SET deleted_at = NULL WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "restore_transcript".into(),
            source,
        })?;
    Ok(())
}

/// Full-text search over transcripts using the FTS5 virtual table created
/// in migration v2. Returns matched transcript rows ordered by FTS rank
/// (best match first). `query` follows FTS5 syntax: bare words AND together
/// by default; quote multi-word phrases; `OR`, `NOT` operators supported.
pub async fn search_transcripts(
    pool: &SqlitePool,
    query: &str,
    limit: i64,
) -> Result<Vec<TranscriptRow>> {
    // The FTS triggers from migration v2 keep `transcripts_fts` in sync
    // for every INSERT/UPDATE/DELETE — including the soft-delete UPDATE,
    // which keeps the row in transcripts_fts. The `t.deleted_at IS NULL`
    // filter on the JOIN keeps trashed rows out of search results.
    let rows = sqlx::query(
        "SELECT t.id, t.text, t.source, t.profile_id, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at, t.starred, t.manual_tags, t.template, t.language, t.segments_json, t.llm_tags \
         FROM transcripts t \
         JOIN transcripts_fts fts ON fts.rowid = t.rowid \
         WHERE transcripts_fts MATCH ? AND t.deleted_at IS NULL \
         ORDER BY fts.rank LIMIT ?",
    )
    .bind(query)
    .bind(limit)
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "search_transcripts".into(),
        source,
    })?;

    Ok(rows.iter().map(transcript_row_from).collect())
}

// --- Task CRUD ---

/// Insert a task. `list_id` and `effort` are nullable (schema predates their
/// UI surfacing); `notes` defaults to '' at the column level. Callers that
/// want to set metadata at creation time pass `Some(...)`; omit for defaults.
///
/// Positional signature is deliberately flat — it mirrors the `tasks`
/// schema columns one-to-one. Refactor to a params struct only if another
/// nullable is added after `energy`.
#[allow(clippy::too_many_arguments)]
pub async fn insert_task(
    pool: &SqlitePool,
    id: &str,
    text: &str,
    bucket: &str,
    source_transcript_id: Option<&str>,
    list_id: Option<&str>,
    effort: Option<&str>,
    energy: Option<&str>,
) -> Result<()> {
    sqlx::query(
        "INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort, energy) \
         VALUES (?, ?, ?, ?, ?, ?, ?)",
    )
    .bind(id)
    .bind(text)
    .bind(bucket)
    .bind(source_transcript_id)
    .bind(list_id)
    .bind(effort)
    .bind(energy)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "insert_task".into(),
        source,
    })?;
    Ok(())
}

pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
    let rows = sqlx::query(
        "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
         source_transcript_id, parent_task_id, energy \
         FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC",
    )
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_tasks".into(),
        source,
    })?;

    Ok(rows.into_iter().map(task_row_from).collect())
}

pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
    let row = sqlx::query(
        "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
         source_transcript_id, parent_task_id, energy FROM tasks WHERE id = ?",
    )
    .bind(id)
    .fetch_optional(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "get_task_by_id".into(),
        source,
    })?;

    Ok(row.map(task_row_from))
}

/// Patch-style update. Each `Option` that is `Some` overwrites the column;
/// `None` preserves the existing value via COALESCE. Returns the refreshed
/// row, or an error if `id` does not exist after the UPDATE.
///
/// Intentionally does not touch `done` / `done_at` — those go through the
/// dedicated `complete_task` / `uncomplete_task` commands because they also
/// set the server-side timestamp.
pub async fn update_task(
    pool: &SqlitePool,
    id: &str,
    text: Option<&str>,
    bucket: Option<&str>,
    list_id: Option<&str>,
    effort: Option<&str>,
    notes: Option<&str>,
) -> Result<TaskRow> {
    sqlx::query(
        "UPDATE tasks SET \
         text = COALESCE(?, text), \
         bucket = COALESCE(?, bucket), \
         list_id = COALESCE(?, list_id), \
         effort = COALESCE(?, effort), \
         notes = COALESCE(?, notes) \
         WHERE id = ?",
    )
    .bind(text)
    .bind(bucket)
    .bind(list_id)
    .bind(effort)
    .bind(notes)
    .bind(id)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "update_task".into(),
        source,
    })?;

    get_task_by_id(pool, id)
        .await?
        .ok_or_else(|| Error::NotFound {
            entity: Entity::Task,
            key: id.to_string(),
        })
}

/// Dedicated tri-state energy setter. Exists as its own function because
/// `update_task` uses `COALESCE(?, col)` to let `None` mean "preserve" —
/// which makes it impossible to explicitly clear energy back to NULL.
/// `set_task_energy` always writes exactly the value passed, including
/// `None` to clear. Returns the refreshed row.
///
/// Caller is responsible for validating `energy` is one of the allowed
/// values; the CHECK constraint will reject anything else at commit time.
pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>) -> Result<TaskRow> {
    sqlx::query("UPDATE tasks SET energy = ? WHERE id = ?")
        .bind(energy)
        .bind(id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "set_task_energy".into(),
            source,
        })?;

    get_task_by_id(pool, id)
        .await?
        .ok_or_else(|| Error::NotFound {
            entity: Entity::Task,
            key: id.to_string(),
        })
}

pub async fn insert_subtask(
    pool: &SqlitePool,
    id: &str,
    text: &str,
    parent_task_id: &str,
) -> Result<()> {
    sqlx::query("INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)")
        .bind(id)
        .bind(text)
        .bind(parent_task_id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "insert_subtask".into(),
            source,
        })?;
    Ok(())
}

pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
    let rows = sqlx::query(
        "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
         source_transcript_id, parent_task_id, energy \
         FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC",
    )
    .bind(parent_id)
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_subtasks".into(),
        source,
    })?;

    Ok(rows.into_iter().map(task_row_from).collect())
}

/// Mark a subtask done. If all siblings are now done, auto-complete the parent.
/// Runs in a transaction so concurrent completions see consistent sibling counts.
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
    let mut tx = pool.begin().await.map_err(|source| Error::Query {
        operation: "complete_subtask_and_check_parent.begin_transaction".into(),
        source,
    })?;

    sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
        .bind(subtask_id)
        .execute(&mut *tx)
        .await
        .map_err(|source| Error::Query {
            operation: "complete_subtask_and_check_parent.complete_subtask".into(),
            source,
        })?;

    let parent_id: Option<String> =
        sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
            .bind(subtask_id)
            .fetch_one(&mut *tx)
            .await
            .map_err(|source| Error::Query {
                operation: "complete_subtask_and_check_parent.get_parent_task_id".into(),
                source,
            })?;

    if let Some(pid) = parent_id {
        let pending: i64 =
            sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0")
                .bind(&pid)
                .fetch_one(&mut *tx)
                .await
                .map_err(|source| Error::Query {
                    operation: "complete_subtask_and_check_parent.count_pending_subtasks".into(),
                    source,
                })?;

        if pending == 0 {
            // Phase 8: flag the cascade so the daily-count query can exclude
            // it. The subtask UPDATE (above) stays at auto_completed = 0. The
            // user explicitly ticked it, so it counts.
            sqlx::query(
                "UPDATE tasks SET done = 1, done_at = datetime('now'), auto_completed = 1 \
                 WHERE id = ?",
            )
            .bind(&pid)
            .execute(&mut *tx)
            .await
            .map_err(|source| Error::Query {
                operation: "complete_subtask_and_check_parent.auto_complete_parent".into(),
                source,
            })?;
        }
    }

    tx.commit().await.map_err(|source| Error::Query {
        operation: "complete_subtask_and_check_parent.commit_transaction".into(),
        source,
    })?;

    Ok(())
}

pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
    sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "complete_task".into(),
            source,
        })?;
    Ok(())
}

pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
    let mut tx = pool.begin().await.map_err(|source| Error::Query {
        operation: "uncomplete_task.begin_transaction".into(),
        source,
    })?;

    sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?")
        .bind(id)
        .execute(&mut *tx)
        .await
        .map_err(|source| Error::Query {
            operation: "uncomplete_task.uncomplete_row".into(),
            source,
        })?;

    // Mirror the auto-complete invariant from
    // `complete_subtask_and_check_parent`: a parent task is done iff
    // every child is done. If the child we just reopened had a done
    // parent, reopen the parent too so state stays consistent
    // (2026-04-22 review MAJOR). No-op for top-level tasks.
    let parent_id: Option<String> =
        sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
            .bind(id)
            .fetch_optional(&mut *tx)
            .await
            .map_err(|source| Error::Query {
                operation: "uncomplete_task.get_parent_task_id".into(),
                source,
            })?
            .flatten();

    if let Some(pid) = parent_id {
        sqlx::query(
            "UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 \
             WHERE id = ? AND done = 1",
        )
        .bind(&pid)
        .execute(&mut *tx)
        .await
        .map_err(|source| Error::Query {
            operation: "uncomplete_task.reopen_parent".into(),
            source,
        })?;
    }

    tx.commit().await.map_err(|source| Error::Query {
        operation: "uncomplete_task.commit_transaction".into(),
        source,
    })?;

    Ok(())
}

pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
    sqlx::query("DELETE FROM tasks WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "delete_task".into(),
            source,
        })?;
    Ok(())
}

// --- Phase 8: daily completion counts -----------------------------------
//
// Drives the Tasks-page badge ("3 today") and the 7-day momentum
// sparkline. Counts every row whose done_at falls on a given local
// day, excluding auto-cascade parents (auto_completed = 1). Subtasks
// count on explicit completion.
//
// The query groups by local calendar day (DATE(done_at, 'localtime')
// since done_at is stored as UTC). The Rust side then left-joins the
// grouped result against a generated N-day spine so empty days are
// explicit zeros, not missing entries. The frontend sparkline wants a
// fixed-length array it can render as 7 bars.

#[derive(Debug, Clone, serde::Serialize)]
pub struct DailyCompletionCount {
    pub day: String, // "YYYY-MM-DD" in local time
    pub count: u32,
}

pub async fn list_recent_completions(
    pool: &SqlitePool,
    days: u32,
) -> Result<Vec<DailyCompletionCount>> {
    // Guard: clamp to [1, 365]. The frontend only ever asks for 7 but
    // a zero or wild value here would produce an empty or huge series.
    let days = days.clamp(1, 365);

    // Pull the grouped counts from SQLite. We do not generate the spine
    // in SQL. It is easier to left-join in Rust and keep the query simple.
    // Use tuple form because the storage crate does not enable sqlx's
    // `derive` feature (FromRow macro is gated behind it).
    let rows: Vec<(String, i64)> = sqlx::query_as(
        "SELECT DATE(done_at, 'localtime') AS day, COUNT(*) AS count \
         FROM tasks \
         WHERE done = 1 \
           AND done_at IS NOT NULL \
           AND auto_completed = 0 \
           AND DATE(done_at, 'localtime') >= DATE('now', 'localtime', ?) \
         GROUP BY day",
    )
    .bind(format!("-{} days", days - 1))
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_recent_completions.fetch_grouped".into(),
        source,
    })?;

    let lookup: std::collections::HashMap<String, u32> = rows
        .into_iter()
        .map(|(day, count)| (day, count.max(0) as u32))
        .collect();

    // Build the spine. `today` is local-day. Walk back `days - 1` entries.
    let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')")
        .fetch_one(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "list_recent_completions.get_local_today".into(),
            source,
        })?;
    let today = today_row.0;

    let mut series = Vec::with_capacity(days as usize);
    for offset in (0..days as i64).rev() {
        // Re-query SQLite for each offset so the date arithmetic is the
        // same calendar SQLite uses (month boundaries, DST, etc.).
        let (day,): (String,) = sqlx::query_as("SELECT DATE(?, ?)")
            .bind(&today)
            .bind(format!("-{offset} days"))
            .fetch_one(pool)
            .await
            .map_err(|source| Error::Query {
                operation: "list_recent_completions.compute_spine_day".into(),
                source,
            })?;
        let count = lookup.get(&day).copied().unwrap_or(0);
        series.push(DailyCompletionCount { day, count });
    }

    Ok(series)
}

// --- Implementation intentions ---

#[allow(clippy::too_many_arguments)]
pub async fn insert_implementation_rule(
    pool: &SqlitePool,
    id: &str,
    enabled: bool,
    trigger_kind: &str,
    trigger_value: &str,
    actions_json: &str,
    last_fired_key: Option<&str>,
) -> Result<ImplementationRuleRow> {
    sqlx::query(
        "INSERT INTO implementation_rules (
            id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key
         ) VALUES (?, ?, ?, ?, ?, ?)",
    )
    .bind(id)
    .bind(enabled as i64)
    .bind(trigger_kind)
    .bind(trigger_value)
    .bind(actions_json)
    .bind(last_fired_key)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "insert_implementation_rule".into(),
        source,
    })?;

    get_implementation_rule(pool, id)
        .await?
        .ok_or_else(|| Error::NotFound {
            entity: Entity::ImplementationRule,
            key: id.to_string(),
        })
}

pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> {
    let rows = sqlx::query(
        "SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \
         created_at, updated_at \
         FROM implementation_rules ORDER BY created_at DESC",
    )
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_implementation_rules".into(),
        source,
    })?;

    Ok(rows.into_iter().map(implementation_rule_row_from).collect())
}

pub async fn get_implementation_rule(
    pool: &SqlitePool,
    id: &str,
) -> Result<Option<ImplementationRuleRow>> {
    let row = sqlx::query(
        "SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \
         created_at, updated_at \
         FROM implementation_rules WHERE id = ?",
    )
    .bind(id)
    .fetch_optional(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "get_implementation_rule".into(),
        source,
    })?;

    Ok(row.map(implementation_rule_row_from))
}

pub async fn set_implementation_rule_enabled(
    pool: &SqlitePool,
    id: &str,
    enabled: bool,
) -> Result<ImplementationRuleRow> {
    sqlx::query(
        "UPDATE implementation_rules
         SET enabled = ?, updated_at = datetime('now')
         WHERE id = ?",
    )
    .bind(enabled as i64)
    .bind(id)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "set_implementation_rule_enabled".into(),
        source,
    })?;

    get_implementation_rule(pool, id)
        .await?
        .ok_or_else(|| Error::NotFound {
            entity: Entity::ImplementationRule,
            key: id.to_string(),
        })
}

pub async fn mark_implementation_rule_fired(
    pool: &SqlitePool,
    id: &str,
    last_fired_key: &str,
) -> Result<ImplementationRuleRow> {
    sqlx::query(
        "UPDATE implementation_rules
         SET last_fired_key = ?, updated_at = datetime('now')
         WHERE id = ?",
    )
    .bind(last_fired_key)
    .bind(id)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "mark_implementation_rule_fired".into(),
        source,
    })?;

    get_implementation_rule(pool, id)
        .await?
        .ok_or_else(|| Error::NotFound {
            entity: Entity::ImplementationRule,
            key: id.to_string(),
        })
}

pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> {
    sqlx::query("DELETE FROM implementation_rules WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "delete_implementation_rule".into(),
            source,
        })?;
    Ok(())
}

// --- Settings CRUD ---

pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
    sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
        .bind(key)
        .bind(value)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "set_setting".into(),
            source,
        })?;
    Ok(())
}

pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>> {
    let row = sqlx::query("SELECT value FROM settings WHERE key = ?")
        .bind(key)
        .fetch_optional(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "get_setting".into(),
            source,
        })?;
    Ok(row.map(|r| r.get("value")))
}

/// One-shot key rename for settings rows carried over from the magnotia era
/// (`magnotia_preferences`, `magnotia_morning_triage_last_shown`, etc.) to
/// the lumotia naming convention.
///
/// Idempotent. Returns `(renamed, orphans_deleted)`:
/// * `renamed` — rows where only the legacy (magnotia) key existed; the
///   row's key is updated to the lumotia equivalent.
/// * `orphans_deleted` — rows where BOTH keys existed; the new (lumotia)
///   row is authoritative and the legacy (magnotia) row is deleted to
///   avoid silent debt.
pub async fn migrate_legacy_setting_keys(pool: &SqlitePool) -> Result<(u64, u64)> {
    let mut tx = pool.begin().await.map_err(|source| Error::Query {
        operation: "migrate_legacy_setting_keys:begin".into(),
        source,
    })?;

    let renamed = sqlx::query(
        "UPDATE settings \
         SET key = 'lumotia_' || substr(key, length('magnotia_') + 1) \
         WHERE key LIKE 'magnotia_%' \
           AND NOT EXISTS (\
               SELECT 1 FROM settings AS dst \
               WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\
           )",
    )
    .execute(&mut *tx)
    .await
    .map_err(|source| Error::Query {
        operation: "migrate_legacy_setting_keys:rename".into(),
        source,
    })?
    .rows_affected();

    let deleted = sqlx::query(
        "DELETE FROM settings \
         WHERE key LIKE 'magnotia_%' \
           AND EXISTS (\
               SELECT 1 FROM settings AS dst \
               WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\
           )",
    )
    .execute(&mut *tx)
    .await
    .map_err(|source| Error::Query {
        operation: "migrate_legacy_setting_keys:delete_orphans".into(),
        source,
    })?
    .rows_affected();

    tx.commit().await.map_err(|source| Error::Query {
        operation: "migrate_legacy_setting_keys:commit".into(),
        source,
    })?;

    Ok((renamed, deleted))
}

// --- Row types ---

#[derive(Debug, Clone)]
pub struct ProfileRow {
    pub id: String,
    pub name: String,
    pub initial_prompt: String,
    pub created_at: String,
}

#[derive(Debug, Clone)]
pub struct ProfileTermRow {
    pub id: String,
    pub profile_id: String,
    pub term: String,
    pub note: String,
    pub created_at: String,
}

#[derive(Debug, Clone)]
pub struct TranscriptRow {
    pub id: String,
    pub text: String,
    pub source: String,
    pub profile_id: String,
    pub title: Option<String>,
    pub audio_path: Option<String>,
    pub duration: f64,
    pub engine: Option<String>,
    pub model_id: Option<String>,
    pub inference_ms: Option<i64>,
    pub sample_rate: Option<i64>,
    pub audio_channels: Option<i64>,
    pub format_mode: Option<String>,
    pub remove_fillers: bool,
    pub british_english: bool,
    pub anti_hallucination: bool,
    pub created_at: String,
    // Task 2.5 — transcripts_meta (migration v5). Persists the UI metadata
    // that previously lived in the removed localStorage `lumotia_history` cache.
    pub starred: bool,
    pub manual_tags: String,
    pub template: String,
    pub language: String,
    pub segments_json: String,
    /// Phase 9 — comma-joined LLM-generated content tags ("topic:foo",
    /// "intent:planning"). Migration v14. Empty string is the normal
    /// no-tags state.
    pub llm_tags: String,
}

#[derive(Debug, Clone)]
pub struct TaskRow {
    pub id: String,
    pub text: String,
    pub bucket: String,
    pub list_id: Option<String>,
    pub effort: Option<String>,
    pub notes: String,
    pub done: bool,
    pub done_at: Option<String>,
    pub created_at: String,
    pub source_transcript_id: Option<String>,
    pub parent_task_id: Option<String>,
    /// Phase 3 energy tagging: one of `"high"`, `"medium"`, `"brain_dead"`,
    /// or `None`. Enforced at the DB layer via a CHECK constraint (see
    /// migration v11). Unset is the expected normal case — the match-my-
    /// energy sort treats unset as Medium-equivalent.
    pub energy: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ImplementationRuleRow {
    pub id: String,
    pub enabled: bool,
    pub trigger_kind: String,
    pub trigger_value: String,
    pub actions_json: String,
    pub last_fired_key: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}

fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
    TranscriptRow {
        id: r.get("id"),
        text: r.get("text"),
        source: r.get("source"),
        profile_id: r.get("profile_id"),
        title: r.get("title"),
        audio_path: r.get("audio_path"),
        duration: r.get("duration"),
        engine: r.get("engine"),
        model_id: r.get("model_id"),
        inference_ms: r.get("inference_ms"),
        sample_rate: r.get("sample_rate"),
        audio_channels: r.get("audio_channels"),
        format_mode: r.get("format_mode"),
        remove_fillers: r.get("remove_fillers"),
        british_english: r.get("british_english"),
        anti_hallucination: r.get("anti_hallucination"),
        created_at: r.get("created_at"),
        // Task 2.5 (migration v5). INTEGER 0/1 → bool via `!= 0`.
        starred: r.get::<i64, _>("starred") != 0,
        manual_tags: r.get("manual_tags"),
        template: r.get("template"),
        language: r.get("language"),
        segments_json: r.get("segments_json"),
        llm_tags: r.get("llm_tags"),
    }
}

fn profile_row_from(r: &sqlx::sqlite::SqliteRow) -> ProfileRow {
    ProfileRow {
        id: r.get("id"),
        name: r.get("name"),
        initial_prompt: r.get("initial_prompt"),
        created_at: r.get("created_at"),
    }
}

fn profile_term_row_from(r: &sqlx::sqlite::SqliteRow) -> ProfileTermRow {
    ProfileTermRow {
        id: r.get("id"),
        profile_id: r.get("profile_id"),
        term: r.get("term"),
        note: r.get("note"),
        created_at: r.get("created_at"),
    }
}

fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
    TaskRow {
        id: r.get("id"),
        text: r.get("text"),
        bucket: r.get("bucket"),
        list_id: r.get("list_id"),
        effort: r.get("effort"),
        notes: r.get("notes"),
        done: r.get("done"),
        done_at: r.get("done_at"),
        created_at: r.get("created_at"),
        source_transcript_id: r.get("source_transcript_id"),
        parent_task_id: r.get("parent_task_id"),
        energy: r.get("energy"),
    }
}

fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow {
    ImplementationRuleRow {
        id: r.get("id"),
        enabled: r.get::<i64, _>("enabled") != 0,
        trigger_kind: r.get("trigger_kind"),
        trigger_value: r.get("trigger_value"),
        actions_json: r.get("actions_json"),
        last_fired_key: r.get("last_fired_key"),
        created_at: r.get("created_at"),
        updated_at: r.get("updated_at"),
    }
}

// --- Profile CRUD (Phase 2 Task 11) ---
//
// Profiles partition the per-user transcription dictionary so that a single
// database can hold multiple named contexts (work, personal, a client, a
// project). Each profile owns an `initial_prompt` that steers Whisper's
// decoder and a list of `profile_terms` that bias recognition and feed
// post-transcription fix-ups.
//
// The `Default` profile (id = DEFAULT_PROFILE_ID, seeded by migration v6)
// must always exist and must never be renamed. Two layers enforce this:
//   1. The DB triggers `trg_protect_default_profile_delete` /
//      `trg_protect_default_profile_rename` from migration v6.
//   2. Rust-layer fail-fast checks below that short-circuit BEFORE the
//      query hits sqlite, so UI callers get a friendly typed
//      `Error::InvalidReference` instead of an opaque
//      `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.

pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> {
    let rows =
        sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC")
            .fetch_all(pool)
            .await
            .map_err(|source| Error::Query {
                operation: "list_profiles".into(),
                source,
            })?;
    Ok(rows.iter().map(profile_row_from).collect())
}

pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result<Option<ProfileRow>> {
    let row = sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles WHERE id = ?")
        .bind(id)
        .fetch_optional(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "get_profile".into(),
            source,
        })?;
    Ok(row.as_ref().map(profile_row_from))
}

pub async fn create_profile(
    pool: &SqlitePool,
    name: &str,
    initial_prompt: &str,
) -> Result<ProfileRow> {
    let id = uuid::Uuid::new_v4().to_string();
    let row = sqlx::query(
        "INSERT INTO profiles (id, name, initial_prompt, created_at) \
         VALUES (?, ?, ?, datetime('now')) \
         RETURNING id, name, initial_prompt, created_at",
    )
    .bind(&id)
    .bind(name)
    .bind(initial_prompt)
    .fetch_one(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "create_profile".into(),
        source,
    })?;
    Ok(profile_row_from(&row))
}

/// Full replace of `name` and `initial_prompt` for a profile.
///
/// Fails fast in Rust — before hitting sqlite — for two Default-profile
/// rules that the migration v6 trigger also enforces but returns opaquely:
///   * the Default id must not be renamed;
///   * no other profile may be renamed to "Default" (would collide with
///     the UNIQUE(name) constraint anyway, but we surface a better error).
pub async fn update_profile(
    pool: &SqlitePool,
    id: &str,
    name: &str,
    initial_prompt: &str,
) -> Result<()> {
    if id == crate::DEFAULT_PROFILE_ID && name != "Default" {
        return Err(Error::InvalidReference {
            entity: Entity::Profile,
            reason: "Default profile cannot be renamed".into(),
        });
    }
    if id != crate::DEFAULT_PROFILE_ID && name == "Default" {
        return Err(Error::InvalidReference {
            entity: Entity::Profile,
            reason: "cannot rename another profile to 'Default'".into(),
        });
    }
    sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?")
        .bind(name)
        .bind(initial_prompt)
        .bind(id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "update_profile".into(),
            source,
        })?;
    Ok(())
}

/// Delete a profile. Rejects the Default profile at the Rust layer so the
/// UI sees a human-readable message instead of a trigger ABORT string.
/// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children.
pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
    if id == crate::DEFAULT_PROFILE_ID {
        return Err(Error::InvalidReference {
            entity: Entity::Profile,
            reason: "Default profile cannot be deleted".into(),
        });
    }
    let transcript_count = transcript_count_for_profile(pool, id).await?;
    if transcript_count > 0 {
        return Err(Error::InvalidReference {
            entity: Entity::Profile,
            reason: format!(
                "cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
            ).into(),
        });
    }
    sqlx::query("DELETE FROM profiles WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "delete_profile".into(),
            source,
        })?;
    Ok(())
}

pub async fn list_profile_terms(
    pool: &SqlitePool,
    profile_id: &str,
) -> Result<Vec<ProfileTermRow>> {
    let rows = sqlx::query(
        "SELECT id, profile_id, term, note, created_at FROM profile_terms \
         WHERE profile_id = ? ORDER BY term ASC",
    )
    .bind(profile_id)
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_profile_terms".into(),
        source,
    })?;
    Ok(rows.iter().map(profile_term_row_from).collect())
}

/// UPSERT on UNIQUE(profile_id, term). Uses ON CONFLICT … DO UPDATE so that
/// re-adding an existing term simply refreshes the note and returns the
/// existing row (not a fresh id).
pub async fn add_profile_term(
    pool: &SqlitePool,
    profile_id: &str,
    term: &str,
    note: &str,
) -> Result<ProfileTermRow> {
    let new_id = uuid::Uuid::new_v4().to_string();
    let row = sqlx::query(
        "INSERT INTO profile_terms (id, profile_id, term, note, created_at) \
         VALUES (?, ?, ?, ?, datetime('now')) \
         ON CONFLICT(profile_id, term) DO UPDATE SET note = excluded.note \
         RETURNING id, profile_id, term, note, created_at",
    )
    .bind(&new_id)
    .bind(profile_id)
    .bind(term)
    .bind(note)
    .fetch_one(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "add_profile_term".into(),
        source,
    })?;
    Ok(profile_term_row_from(&row))
}

pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
    sqlx::query("DELETE FROM profile_terms WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "delete_profile_term".into(),
            source,
        })?;
    Ok(())
}

async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
    let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM profiles WHERE id = ? LIMIT 1")
        .bind(id)
        .fetch_optional(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "profile_exists".into(),
            source,
        })?;
    Ok(exists.is_some())
}

async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64> {
    sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE profile_id = ?")
        .bind(id)
        .fetch_one(pool)
        .await
        .map_err(|source| Error::Query {
            operation: "transcript_count_for_profile".into(),
            source,
        })
}

// --- Error Logging ---

/// Log a structured error to the `error_log` table.
///
/// Available for Tauri command handlers to persist errors for diagnostics.
/// Each entry records context (which subsystem), an optional error code,
/// the human-readable message, and optional JSON metadata.
///
/// # Example
/// ```ignore
/// log_error(&pool, "transcription", Some("WHISPER_INIT"), "Model load failed", None).await?;
/// ```
///
/// TODO: Wire this into Tauri command error paths so runtime failures are
/// persisted for user-facing error history and crash diagnostics.
pub async fn log_error(
    pool: &SqlitePool,
    context: &str,
    error_code: Option<&str>,
    message: &str,
    metadata: Option<&str>,
) -> Result<()> {
    sqlx::query(
        "INSERT INTO error_log (context, error_code, message, metadata) VALUES (?, ?, ?, ?)",
    )
    .bind(context)
    .bind(error_code)
    .bind(message)
    .bind(metadata)
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "log_error".into(),
        source,
    })?;
    Ok(())
}

/// Read the most recent N rows from `error_log`, newest first. Used by the
/// diagnostic-report bundler in Settings → About.
#[derive(Debug, Clone)]
pub struct ErrorLogRow {
    pub id: i64,
    pub timestamp: String,
    pub context: String,
    pub error_code: Option<String>,
    pub message: String,
    pub metadata: Option<String>,
}

/// Delete `error_log` rows older than `keep_days` whole days. Returns the
/// row count removed.
///
/// Called once on app startup so the table doesn't grow unbounded across
/// months of dogfooding — without this it would eventually balloon the
/// diagnostic-bundle export and slow the `list_recent_errors` query.
/// 90 days is the default: long enough to triage a "this happened a few
/// weeks ago" report, short enough that the table stays kilobyte-scale.
pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result<u64> {
    let result = sqlx::query(
        "DELETE FROM error_log \
         WHERE timestamp < datetime('now', ?)",
    )
    .bind(format!("-{keep_days} days"))
    .execute(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "prune_error_log".into(),
        source,
    })?;
    Ok(result.rows_affected())
}

pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<ErrorLogRow>> {
    let rows = sqlx::query(
        "SELECT id, timestamp, context, error_code, message, metadata \
         FROM error_log ORDER BY id DESC LIMIT ?",
    )
    .bind(limit)
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_recent_errors".into(),
        source,
    })?;

    Ok(rows
        .into_iter()
        .map(|r| ErrorLogRow {
            id: r.get("id"),
            timestamp: r.get("timestamp"),
            context: r.get("context"),
            error_code: r.get("error_code"),
            message: r.get("message"),
            metadata: r.get("metadata"),
        })
        .collect())
}

// --- Feedback (HITL) -------------------------------------------------------
//
// Phase 2 of the feature-complete roadmap: capture thumbs + corrections on
// AI-generated output so the prompt builder can inject recent examples as
// few-shot exemplars. Storage-only here; the prompt-conditioning logic lives
// in lumotia-llm. Retrieval returns the most recent rows, narrowed to the
// active profile when provided so feedback does not cross profiles.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedbackTargetType {
    MicroStep,
    TaskExtraction,
    Cleanup,
}

impl FeedbackTargetType {
    pub fn as_str(self) -> &'static str {
        match self {
            FeedbackTargetType::MicroStep => "microstep",
            FeedbackTargetType::TaskExtraction => "task_extraction",
            FeedbackTargetType::Cleanup => "cleanup",
        }
    }

    /// Parse the database `target_type` string back into the enum.
    /// Named `parse` rather than `from_str` so it does not collide with
    /// the `std::str::FromStr` trait — the trait is overkill here
    /// because callers never want a `FromStr::Err` and already know the
    /// set of valid values at the call site.
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "microstep" => Some(FeedbackTargetType::MicroStep),
            "task_extraction" => Some(FeedbackTargetType::TaskExtraction),
            "cleanup" => Some(FeedbackTargetType::Cleanup),
            _ => None,
        }
    }
}

#[derive(Debug, Clone)]
pub struct RecordFeedbackParams {
    pub target_type: FeedbackTargetType,
    pub target_id: Option<String>,
    /// -1 = thumbs down, 0 = correction (neutral), +1 = thumbs up.
    pub rating: i8,
    pub original_text: Option<String>,
    pub corrected_text: Option<String>,
    pub context_json: Option<String>,
    pub profile_id: Option<String>,
}

#[derive(Debug, Clone)]
pub struct FeedbackRow {
    pub id: i64,
    pub target_type: String,
    pub target_id: Option<String>,
    pub rating: i64,
    pub original_text: Option<String>,
    pub corrected_text: Option<String>,
    pub context_json: Option<String>,
    pub profile_id: String,
    pub created_at: String,
}

pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> {
    if !matches!(params.rating, -1..=1) {
        return Err(Error::InvalidReference {
            entity: Entity::Feedback,
            reason: format!("invalid rating {} (must be -1, 0, or 1)", params.rating).into(),
        });
    }
    let profile_id = params
        .profile_id
        .unwrap_or_else(|| crate::DEFAULT_PROFILE_ID.to_string());
    let row = sqlx::query(
        "INSERT INTO feedback (
            target_type, target_id, rating,
            original_text, corrected_text, context_json, profile_id
         ) VALUES (?, ?, ?, ?, ?, ?, ?)
         RETURNING id",
    )
    .bind(params.target_type.as_str())
    .bind(params.target_id)
    .bind(params.rating as i64)
    .bind(params.original_text)
    .bind(params.corrected_text)
    .bind(params.context_json)
    .bind(profile_id)
    .fetch_one(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "record_feedback".into(),
        source,
    })?;
    Ok(row.get::<i64, _>("id"))
}

/// Fetch the most recent feedback rows for a given target type, scoped to
/// the active profile. Used by the prompt builder to gather few-shot
/// exemplars. Orders by `created_at DESC` so the most recent corrections
/// outweigh older ones — the user's style drifts, and we want the LLM
/// to track the current preference.
///
/// `min_rating` filters out thumbs-down examples when the caller only
/// wants positive reinforcement; pass `-1` to include everything.
pub async fn list_feedback_examples(
    pool: &SqlitePool,
    target_type: FeedbackTargetType,
    limit: i64,
    min_rating: i8,
    profile_id: Option<&str>,
) -> Result<Vec<FeedbackRow>> {
    let pid = profile_id.unwrap_or(crate::DEFAULT_PROFILE_ID);
    let rows = sqlx::query(
        "SELECT id, target_type, target_id, rating,
                original_text, corrected_text, context_json,
                profile_id, created_at
         FROM feedback
         WHERE target_type = ?
           AND profile_id = ?
           AND rating >= ?
         ORDER BY created_at DESC, id DESC
         LIMIT ?",
    )
    .bind(target_type.as_str())
    .bind(pid)
    .bind(min_rating as i64)
    .bind(limit)
    .fetch_all(pool)
    .await
    .map_err(|source| Error::Query {
        operation: "list_feedback_examples".into(),
        source,
    })?;

    Ok(rows
        .into_iter()
        .map(|r| FeedbackRow {
            id: r.get("id"),
            target_type: r.get("target_type"),
            target_id: r.get("target_id"),
            rating: r.get("rating"),
            original_text: r.get("original_text"),
            corrected_text: r.get("corrected_text"),
            context_json: r.get("context_json"),
            profile_id: r.get("profile_id"),
            created_at: r.get("created_at"),
        })
        .collect())
}

#[cfg(test)]
mod tests {
    use super::*;

    async fn test_pool() -> SqlitePool {
        let pool = SqlitePoolOptions::new()
            .max_connections(1)
            .connect("sqlite::memory:")
            .await
            .unwrap();
        sqlx::query("PRAGMA foreign_keys = ON")
            .execute(&pool)
            .await
            .unwrap();
        run_migrations(&pool).await.unwrap();
        pool
    }

    #[tokio::test]
    async fn transcript_crud_roundtrip() {
        let pool = test_pool().await;

        insert_transcript(
            &pool,
            &InsertTranscriptParams {
                id: "t1",
                text: "Hello world",
                source: "microphone",
                profile_id: crate::DEFAULT_PROFILE_ID,
                title: Some("Test"),
                audio_path: None,
                duration: 1.5,
                engine: Some("whisper"),
                model_id: Some("whisper-tiny-en"),
                inference_ms: Some(250),
                sample_rate: Some(48000),
                audio_channels: Some(1),
                format_mode: Some("Clean"),
                remove_fillers: true,
                british_english: true,
                anti_hallucination: false,
            },
        )
        .await
        .unwrap();

        let t = get_transcript(&pool, "t1").await.unwrap().unwrap();
        assert_eq!(t.text, "Hello world");
        assert_eq!(t.source, "microphone");
        assert_eq!(t.profile_id, crate::DEFAULT_PROFILE_ID);
        assert_eq!(t.duration, 1.5);
        assert_eq!(t.engine.as_deref(), Some("whisper"));
        assert_eq!(t.model_id.as_deref(), Some("whisper-tiny-en"));
        assert_eq!(t.inference_ms, Some(250));
        assert!(t.remove_fillers);
        assert!(t.british_english);

        let list = list_transcripts(&pool, 10).await.unwrap();
        assert_eq!(list.len(), 1);

        delete_transcript(&pool, "t1").await.unwrap();
        let deleted = get_transcript(&pool, "t1").await.unwrap();
        assert!(deleted.is_none());
    }

    #[tokio::test]
    async fn search_transcripts_uses_fts5_and_ranks_by_relevance() {
        // Locks in the FTS5 behaviour contract: MATCH-based substring-like
        // search (case-insensitive tokens) with rank-ordered results, so
        // anyone swapping `search_transcripts` out for embeddings later
        // knows what invariants they must preserve.
        let pool = test_pool().await;

        let rows = [
            ("t1", "The Parakeet speech model runs on sherpa-onnx."),
            (
                "t2",
                "Parakeet parakeet parakeet dominates English benchmarks.",
            ),
            ("t3", "Whisper large-v3 remains the multilingual champion."),
        ];

        for (id, text) in rows {
            insert_transcript(
                &pool,
                &InsertTranscriptParams {
                    id,
                    text,
                    source: "microphone",
                    profile_id: crate::DEFAULT_PROFILE_ID,
                    title: None,
                    audio_path: None,
                    duration: 1.0,
                    engine: Some("whisper"),
                    model_id: Some("whisper-tiny-en"),
                    inference_ms: None,
                    sample_rate: None,
                    audio_channels: None,
                    format_mode: None,
                    remove_fillers: false,
                    british_english: false,
                    anti_hallucination: false,
                },
            )
            .await
            .unwrap();
        }

        let hits = search_transcripts(&pool, "parakeet", 10).await.unwrap();
        let ids: Vec<&str> = hits.iter().map(|row| row.id.as_str()).collect();
        assert_eq!(
            ids,
            vec!["t2", "t1"],
            "t3 has no 'parakeet' token; t2 outranks t1 on repetition"
        );

        let no_match = search_transcripts(&pool, "moonshine", 10).await.unwrap();
        assert!(no_match.is_empty());
    }

    #[tokio::test]
    async fn task_crud_roundtrip() {
        let pool = test_pool().await;

        insert_task(
            &pool,
            "task1",
            "Buy groceries",
            "today",
            None,
            None,
            None,
            None,
        )
        .await
        .unwrap();

        let tasks = list_tasks(&pool).await.unwrap();
        assert_eq!(tasks.len(), 1);
        assert_eq!(tasks[0].text, "Buy groceries");
        assert!(!tasks[0].done);

        complete_task(&pool, "task1").await.unwrap();
        let tasks = list_tasks(&pool).await.unwrap();
        assert!(tasks[0].done);

        delete_task(&pool, "task1").await.unwrap();
        let tasks = list_tasks(&pool).await.unwrap();
        assert!(tasks.is_empty());
    }

    #[tokio::test]
    async fn subtask_crud_roundtrip() {
        let pool = test_pool().await;
        insert_task(&pool, "p1", "Write report", "inbox", None, None, None, None)
            .await
            .unwrap();
        insert_subtask(&pool, "s1", "Open document", "p1")
            .await
            .unwrap();
        insert_subtask(&pool, "s2", "Write introduction", "p1")
            .await
            .unwrap();

        // list_tasks must exclude subtasks
        let top_level = list_tasks(&pool).await.unwrap();
        assert_eq!(top_level.len(), 1);
        assert_eq!(top_level[0].id, "p1");

        // list_subtasks returns both children
        let subs = list_subtasks(&pool, "p1").await.unwrap();
        assert_eq!(subs.len(), 2);

        // completing s1 should NOT auto-complete parent (s2 still pending)
        complete_subtask_and_check_parent(&pool, "s1")
            .await
            .unwrap();
        let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
        assert!(!parent.done, "parent should not be done yet");

        // completing s2 SHOULD auto-complete parent
        complete_subtask_and_check_parent(&pool, "s2")
            .await
            .unwrap();
        let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
        assert!(
            parent.done,
            "parent should auto-complete when all children done"
        );
    }

    #[tokio::test]
    async fn uncomplete_subtask_reopens_auto_completed_parent() {
        // Regression for the 2026-04-22 review MAJOR on
        // asymmetric complete / uncomplete semantics: once all
        // subtasks completed auto-completes the parent, reopening a
        // child must also reopen the parent so "parent is done iff
        // every child is done" holds in both directions.
        let pool = test_pool().await;
        insert_task(&pool, "p1", "Ship release", "inbox", None, None, None, None)
            .await
            .unwrap();
        insert_subtask(&pool, "s1", "Final test", "p1")
            .await
            .unwrap();
        insert_subtask(&pool, "s2", "Tag release", "p1")
            .await
            .unwrap();

        complete_subtask_and_check_parent(&pool, "s1")
            .await
            .unwrap();
        complete_subtask_and_check_parent(&pool, "s2")
            .await
            .unwrap();
        let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
        assert!(parent.done);

        uncomplete_task(&pool, "s2").await.unwrap();
        let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
        assert!(
            !parent.done,
            "reopening any child must reopen the auto-completed parent"
        );
        let s2 = get_task_by_id(&pool, "s2").await.unwrap().unwrap();
        assert!(!s2.done, "subtask itself must also be reopened");
    }

    #[tokio::test]
    async fn uncomplete_top_level_task_does_not_touch_siblings() {
        // Sanity: tasks without a parent must not trigger the parent-
        // reopen branch (it should no-op cleanly).
        let pool = test_pool().await;
        insert_task(&pool, "a", "A", "inbox", None, None, None, None)
            .await
            .unwrap();
        insert_task(&pool, "b", "B", "inbox", None, None, None, None)
            .await
            .unwrap();
        complete_task(&pool, "a").await.unwrap();
        complete_task(&pool, "b").await.unwrap();

        uncomplete_task(&pool, "a").await.unwrap();

        let a = get_task_by_id(&pool, "a").await.unwrap().unwrap();
        let b = get_task_by_id(&pool, "b").await.unwrap().unwrap();
        assert!(!a.done);
        assert!(b.done, "sibling must be untouched");
    }

    #[tokio::test]
    async fn implementation_rule_crud_roundtrip() {
        let pool = test_pool().await;

        let rule = insert_implementation_rule(
            &pool,
            "rule-1",
            true,
            "time_of_day",
            "09:00",
            r#"[{"kind":"speak_line","text":"time to plan the day"}]"#,
            Some("2026-04-24"),
        )
        .await
        .unwrap();

        assert_eq!(rule.id, "rule-1");
        assert!(rule.enabled);
        assert_eq!(rule.trigger_kind, "time_of_day");
        assert_eq!(rule.trigger_value, "09:00");
        assert_eq!(rule.last_fired_key.as_deref(), Some("2026-04-24"));

        let rules = list_implementation_rules(&pool).await.unwrap();
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].actions_json, rule.actions_json);

        let disabled = set_implementation_rule_enabled(&pool, "rule-1", false)
            .await
            .unwrap();
        assert!(!disabled.enabled);

        let fired = mark_implementation_rule_fired(&pool, "rule-1", "2026-04-25")
            .await
            .unwrap();
        assert_eq!(fired.last_fired_key.as_deref(), Some("2026-04-25"));

        delete_implementation_rule(&pool, "rule-1").await.unwrap();
        assert!(list_implementation_rules(&pool).await.unwrap().is_empty());
    }

    #[tokio::test]
    async fn update_transcript_meta_happy_path() {
        // Task 2.5 — insert a transcript, update starred=true, read it back.
        let pool = test_pool().await;
        insert_transcript(
            &pool,
            &InsertTranscriptParams {
                id: "tm1",
                text: "body",
                source: "microphone",
                profile_id: crate::DEFAULT_PROFILE_ID,
                title: Some("Meta happy"),
                audio_path: None,
                duration: 0.0,
                engine: None,
                model_id: None,
                inference_ms: None,
                sample_rate: None,
                audio_channels: None,
                format_mode: None,
                remove_fillers: false,
                british_english: false,
                anti_hallucination: false,
            },
        )
        .await
        .unwrap();

        let row = update_transcript_meta(&pool, "tm1", Some(true), None, None, None, None, None)
            .await
            .unwrap();
        assert!(row.starred, "starred must flip true after update");

        let fetched = get_transcript(&pool, "tm1").await.unwrap().unwrap();
        assert!(fetched.starred, "starred must persist via SELECT");
        assert_eq!(fetched.manual_tags, "");
        assert_eq!(fetched.template, "");
        assert_eq!(fetched.language, "");
        assert_eq!(fetched.segments_json, "");
        assert_eq!(fetched.llm_tags, "", "llm_tags defaults to empty");
    }

    #[tokio::test]
    async fn update_transcript_meta_partial_leaves_others_unchanged() {
        // Task 2.5 — partial update: only segments_json changes; the other
        // meta columns must be preserved by COALESCE.
        let pool = test_pool().await;
        insert_transcript(
            &pool,
            &InsertTranscriptParams {
                id: "tm2",
                text: "body",
                source: "microphone",
                profile_id: crate::DEFAULT_PROFILE_ID,
                title: None,
                audio_path: None,
                duration: 0.0,
                engine: None,
                model_id: None,
                inference_ms: None,
                sample_rate: None,
                audio_channels: None,
                format_mode: None,
                remove_fillers: false,
                british_english: false,
                anti_hallucination: false,
            },
        )
        .await
        .unwrap();

        // Seed starred + tags + template + language first.
        update_transcript_meta(
            &pool,
            "tm2",
            Some(true),
            Some("urgent,review"),
            Some("Meeting"),
            Some("en-GB"),
            None,
            None,
        )
        .await
        .unwrap();

        // Now update only segments_json.
        let row = update_transcript_meta(
            &pool,
            "tm2",
            None,
            None,
            None,
            None,
            Some(r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#),
            None,
        )
        .await
        .unwrap();

        assert!(row.starred, "starred must survive partial update");
        assert_eq!(row.manual_tags, "urgent,review");
        assert_eq!(row.template, "Meeting");
        assert_eq!(row.language, "en-GB");
        assert_eq!(
            row.segments_json,
            r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#
        );
    }

    #[tokio::test]
    async fn update_transcript_meta_writes_llm_tags() {
        // Phase 9 — llm_tags writes via update_transcript_meta and round-
        // trips via SELECT. Migration v14 default is empty string.
        let pool = test_pool().await;
        insert_transcript(
            &pool,
            &InsertTranscriptParams {
                id: "tm-llm",
                text: "body",
                source: "microphone",
                profile_id: crate::DEFAULT_PROFILE_ID,
                title: None,
                audio_path: None,
                duration: 0.0,
                engine: None,
                model_id: None,
                inference_ms: None,
                sample_rate: None,
                audio_channels: None,
                format_mode: None,
                remove_fillers: false,
                british_english: false,
                anti_hallucination: false,
            },
        )
        .await
        .unwrap();

        let initial = get_transcript(&pool, "tm-llm").await.unwrap().unwrap();
        assert_eq!(initial.llm_tags, "", "default after insert");

        update_transcript_meta(
            &pool,
            "tm-llm",
            None,
            None,
            None,
            None,
            None,
            Some("topic:grant-application,intent:planning"),
        )
        .await
        .unwrap();

        let fetched = get_transcript(&pool, "tm-llm").await.unwrap().unwrap();
        assert_eq!(fetched.llm_tags, "topic:grant-application,intent:planning",);
        assert_eq!(fetched.manual_tags, "", "manual_tags untouched");
    }

    #[tokio::test]
    async fn update_task_overwrites_provided_fields() {
        // Task 2.6 — happy path: insert, update bucket + effort, read back.
        let pool = test_pool().await;
        insert_task(&pool, "u1", "Draft post", "inbox", None, None, None, None)
            .await
            .unwrap();

        let row = update_task(&pool, "u1", None, Some("today"), None, Some("15m"), None)
            .await
            .unwrap();
        assert_eq!(row.bucket, "today");
        assert_eq!(row.effort.as_deref(), Some("15m"));
        assert_eq!(row.text, "Draft post", "text must be unchanged");
        assert_eq!(row.notes, "", "notes default must survive");
    }

    #[tokio::test]
    async fn update_task_partial_leaves_others_unchanged() {
        // Task 2.6 — partial update: only notes changes; text/bucket intact.
        let pool = test_pool().await;
        insert_task(
            &pool,
            "u2",
            "Prep slides",
            "today",
            None,
            None,
            Some("30m"),
            None,
        )
        .await
        .unwrap();

        let row = update_task(
            &pool,
            "u2",
            None,
            None,
            None,
            None,
            Some("remember venue wifi"),
        )
        .await
        .unwrap();
        assert_eq!(row.text, "Prep slides");
        assert_eq!(row.bucket, "today");
        assert_eq!(row.effort.as_deref(), Some("30m"));
        assert_eq!(row.notes, "remember venue wifi");
    }

    #[tokio::test]
    async fn update_task_missing_id_errors() {
        let pool = test_pool().await;
        let res = update_task(&pool, "missing", Some("x"), None, None, None, None).await;
        assert!(
            res.is_err(),
            "update_task must error when id does not exist"
        );
    }

    // --- Energy tagging (Phase 3) ---

    #[tokio::test]
    async fn set_task_energy_round_trip_includes_explicit_clear() {
        let pool = test_pool().await;
        insert_task(&pool, "e1", "Write report", "inbox", None, None, None, None)
            .await
            .unwrap();

        // Fresh task: energy must be NULL.
        let t = get_task_by_id(&pool, "e1").await.unwrap().unwrap();
        assert!(t.energy.is_none(), "new task must start with energy unset");

        // Set High.
        let t = set_task_energy(&pool, "e1", Some("high")).await.unwrap();
        assert_eq!(t.energy.as_deref(), Some("high"));

        // Change to Medium.
        let t = set_task_energy(&pool, "e1", Some("medium")).await.unwrap();
        assert_eq!(t.energy.as_deref(), Some("medium"));

        // Explicit clear back to NULL — the whole reason this function
        // exists separately from update_task's COALESCE semantics.
        let t = set_task_energy(&pool, "e1", None).await.unwrap();
        assert!(
            t.energy.is_none(),
            "set_task_energy(None) must explicitly clear the column"
        );
    }

    #[tokio::test]
    async fn set_task_energy_rejects_unknown_value_via_check_constraint() {
        // Migration v11 defines a CHECK constraint; invalid values must
        // be rejected at the DB layer even if a caller bypasses frontend
        // validation.
        let pool = test_pool().await;
        insert_task(&pool, "e2", "Task", "inbox", None, None, None, None)
            .await
            .unwrap();
        let res = set_task_energy(&pool, "e2", Some("turbo")).await;
        assert!(
            res.is_err(),
            "CHECK constraint must reject energy values outside the enum"
        );
    }

    // --- Profile CRUD tests (Task 11) ---

    #[tokio::test]
    async fn list_profiles_returns_seeded_default() {
        // Happy path: a fresh DB has exactly the seeded Default profile.
        let pool = test_pool().await;
        let profiles = list_profiles(&pool).await.unwrap();
        assert_eq!(profiles.len(), 1, "Default profile must be seeded");
        assert_eq!(profiles[0].id, crate::DEFAULT_PROFILE_ID);
        assert_eq!(profiles[0].name, "Default");
    }

    #[tokio::test]
    async fn get_profile_happy_path_and_missing() {
        let pool = test_pool().await;
        let p = get_profile(&pool, crate::DEFAULT_PROFILE_ID).await.unwrap();
        assert!(p.is_some());
        assert_eq!(p.unwrap().name, "Default");

        // Guardrail: unknown id returns None, not an error.
        let missing = get_profile(&pool, "00000000-0000-0000-0000-000000000099")
            .await
            .unwrap();
        assert!(missing.is_none());
    }

    #[tokio::test]
    async fn create_profile_happy_path() {
        let pool = test_pool().await;
        let row = create_profile(&pool, "Work", "You are a meeting notes assistant.")
            .await
            .unwrap();
        assert_eq!(row.name, "Work");
        assert_eq!(row.initial_prompt, "You are a meeting notes assistant.");
        assert!(!row.id.is_empty());
        assert_ne!(row.id, crate::DEFAULT_PROFILE_ID);

        let profiles = list_profiles(&pool).await.unwrap();
        assert_eq!(profiles.len(), 2, "Default + Work");
    }

    #[tokio::test]
    async fn create_profile_rejects_duplicate_name() {
        // Guardrail: UNIQUE(name) collision surfaces as Error::Query.
        let pool = test_pool().await;
        create_profile(&pool, "Personal", "").await.unwrap();
        let res = create_profile(&pool, "Personal", "").await;
        assert!(res.is_err(), "duplicate name must fail");
    }

    #[tokio::test]
    async fn update_profile_happy_path() {
        let pool = test_pool().await;
        let created = create_profile(&pool, "ClientA", "prompt v1").await.unwrap();
        update_profile(&pool, &created.id, "ClientA Renamed", "prompt v2")
            .await
            .unwrap();
        let fetched = get_profile(&pool, &created.id).await.unwrap().unwrap();
        assert_eq!(fetched.name, "ClientA Renamed");
        assert_eq!(fetched.initial_prompt, "prompt v2");
    }

    #[tokio::test]
    async fn update_profile_rejects_renaming_default() {
        // Guardrail: Rust layer rejects renaming the Default id; must not
        // reach sqlite trigger.
        let pool = test_pool().await;
        let res = update_profile(&pool, crate::DEFAULT_PROFILE_ID, "NotDefault", "").await;
        let msg = format!("{}", res.expect_err("must reject rename of Default"));
        assert!(
            msg.contains("Default profile cannot be renamed"),
            "got: {msg}"
        );
    }

    #[tokio::test]
    async fn update_profile_rejects_renaming_other_to_default() {
        // Guardrail: another profile cannot adopt the name "Default".
        let pool = test_pool().await;
        let other = create_profile(&pool, "Temp", "").await.unwrap();
        let res = update_profile(&pool, &other.id, "Default", "").await;
        let msg = format!("{}", res.expect_err("must reject adopting 'Default'"));
        assert!(msg.contains("Default"), "got: {msg}");
    }

    #[tokio::test]
    async fn delete_profile_happy_path() {
        let pool = test_pool().await;
        let created = create_profile(&pool, "Ephemeral", "").await.unwrap();
        delete_profile(&pool, &created.id).await.unwrap();
        let fetched = get_profile(&pool, &created.id).await.unwrap();
        assert!(fetched.is_none());
    }

    #[tokio::test]
    async fn delete_profile_rejects_default() {
        let pool = test_pool().await;
        let res = delete_profile(&pool, crate::DEFAULT_PROFILE_ID).await;
        let msg = format!("{}", res.expect_err("must reject delete of Default"));
        assert!(
            msg.contains("Default profile cannot be deleted"),
            "got: {msg}"
        );
        // And Default must still be listed.
        let profiles = list_profiles(&pool).await.unwrap();
        assert_eq!(profiles.len(), 1);
    }

    #[tokio::test]
    async fn insert_transcript_rejects_unknown_profile_id() {
        let pool = test_pool().await;
        let err = insert_transcript(
            &pool,
            &InsertTranscriptParams {
                id: "bad-profile",
                text: "Hello",
                source: "microphone",
                profile_id: "profile-missing",
                title: None,
                audio_path: None,
                duration: 0.0,
                engine: None,
                model_id: None,
                inference_ms: None,
                sample_rate: None,
                audio_channels: None,
                format_mode: None,
                remove_fillers: false,
                british_english: false,
                anti_hallucination: false,
            },
        )
        .await
        .expect_err("unknown profile id must be rejected");

        let msg = err.to_string();
        assert!(msg.contains("unknown profile id"), "got: {msg}");
    }

    #[tokio::test]
    async fn delete_profile_rejects_when_transcripts_reference_it() {
        let pool = test_pool().await;
        let profile = create_profile(&pool, "Referenced", "").await.unwrap();
        insert_transcript(
            &pool,
            &InsertTranscriptParams {
                id: "referenced-transcript",
                text: "Hello",
                source: "microphone",
                profile_id: &profile.id,
                title: None,
                audio_path: None,
                duration: 0.0,
                engine: None,
                model_id: None,
                inference_ms: None,
                sample_rate: None,
                audio_channels: None,
                format_mode: None,
                remove_fillers: false,
                british_english: false,
                anti_hallucination: false,
            },
        )
        .await
        .unwrap();

        let err = delete_profile(&pool, &profile.id)
            .await
            .expect_err("profile with transcript references must not delete");
        let msg = err.to_string();
        assert!(msg.contains("reassign transcripts first"), "got: {msg}");
    }

    #[tokio::test]
    async fn list_profile_terms_happy_path() {
        let pool = test_pool().await;
        let p = create_profile(&pool, "WithTerms", "").await.unwrap();
        add_profile_term(&pool, &p.id, "CORBEL", "company name")
            .await
            .unwrap();
        add_profile_term(&pool, &p.id, "Wren", "agent name")
            .await
            .unwrap();
        let terms = list_profile_terms(&pool, &p.id).await.unwrap();
        assert_eq!(terms.len(), 2);
        // Sorted alphabetically.
        assert_eq!(terms[0].term, "CORBEL");
        assert_eq!(terms[1].term, "Wren");
    }

    #[tokio::test]
    async fn list_profile_terms_filters_by_profile() {
        // Guardrail: terms from one profile must not leak into another.
        let pool = test_pool().await;
        let a = create_profile(&pool, "A", "").await.unwrap();
        let b = create_profile(&pool, "B", "").await.unwrap();
        add_profile_term(&pool, &a.id, "alpha", "").await.unwrap();
        add_profile_term(&pool, &b.id, "beta", "").await.unwrap();
        let a_terms = list_profile_terms(&pool, &a.id).await.unwrap();
        let b_terms = list_profile_terms(&pool, &b.id).await.unwrap();
        assert_eq!(a_terms.len(), 1);
        assert_eq!(a_terms[0].term, "alpha");
        assert_eq!(b_terms.len(), 1);
        assert_eq!(b_terms[0].term, "beta");
    }

    #[tokio::test]
    async fn add_profile_term_happy_path() {
        let pool = test_pool().await;
        let p = create_profile(&pool, "P", "").await.unwrap();
        let row = add_profile_term(&pool, &p.id, "ACME", "client")
            .await
            .unwrap();
        assert_eq!(row.term, "ACME");
        assert_eq!(row.note, "client");
        assert_eq!(row.profile_id, p.id);
    }

    #[tokio::test]
    async fn add_profile_term_upsert_on_duplicate_term() {
        // Guardrail: UPSERT refreshes note and reuses the existing row id
        // rather than violating UNIQUE(profile_id, term).
        let pool = test_pool().await;
        let p = create_profile(&pool, "P2", "").await.unwrap();
        let first = add_profile_term(&pool, &p.id, "ACME", "first note")
            .await
            .unwrap();
        let second = add_profile_term(&pool, &p.id, "ACME", "second note")
            .await
            .unwrap();
        assert_eq!(first.id, second.id, "UPSERT must preserve row id");
        assert_eq!(second.note, "second note");
        let terms = list_profile_terms(&pool, &p.id).await.unwrap();
        assert_eq!(terms.len(), 1, "UPSERT must not create a second row");
    }

    #[tokio::test]
    async fn delete_profile_term_happy_path() {
        let pool = test_pool().await;
        let p = create_profile(&pool, "P3", "").await.unwrap();
        let t = add_profile_term(&pool, &p.id, "Foo", "").await.unwrap();
        delete_profile_term(&pool, &t.id).await.unwrap();
        let remaining = list_profile_terms(&pool, &p.id).await.unwrap();
        assert!(remaining.is_empty());
    }

    #[tokio::test]
    async fn delete_profile_term_missing_id_is_noop() {
        // Guardrail: deleting a non-existent term must not error; sqlite
        // DELETE on zero rows is a success. Verifies we're not hand-rolling
        // an existence check that would regress the UI's happy path on
        // double-click delete.
        let pool = test_pool().await;
        let p = create_profile(&pool, "NoopProfile", "").await.unwrap();
        let t = add_profile_term(&pool, &p.id, "real", "").await.unwrap();
        delete_profile_term(&pool, "00000000-dead-beef-0000-000000000000")
            .await
            .expect("missing id must be a silent no-op");
        // Real term still exists.
        let terms = list_profile_terms(&pool, &p.id).await.unwrap();
        assert_eq!(terms.len(), 1);
        assert_eq!(terms[0].id, t.id);
    }

    #[tokio::test]
    async fn delete_profile_cascades_to_terms() {
        // Guardrail: deleting a profile must cascade-delete its terms via
        // ON DELETE CASCADE (migration v6 FK).
        let pool = test_pool().await;
        let p = create_profile(&pool, "Doomed", "").await.unwrap();
        add_profile_term(&pool, &p.id, "x", "").await.unwrap();
        add_profile_term(&pool, &p.id, "y", "").await.unwrap();
        delete_profile(&pool, &p.id).await.unwrap();
        // Using a raw query because list_profile_terms would happily
        // return [] even if the FK had been forgotten. We want to prove
        // the child rows are actually gone from the table.
        let count: i64 =
            sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?")
                .bind(&p.id)
                .fetch_one(&pool)
                .await
                .unwrap();
        assert_eq!(count, 0, "terms must cascade-delete with their profile");
    }

    #[tokio::test]
    async fn settings_crud_roundtrip() {
        let pool = test_pool().await;

        set_setting(&pool, "theme", "dark").await.unwrap();
        let val = get_setting(&pool, "theme").await.unwrap();
        assert_eq!(val.as_deref(), Some("dark"));

        set_setting(&pool, "theme", "light").await.unwrap();
        let val = get_setting(&pool, "theme").await.unwrap();
        assert_eq!(val.as_deref(), Some("light"));

        let missing = get_setting(&pool, "nonexistent").await.unwrap();
        assert!(missing.is_none());
    }

    #[tokio::test]
    async fn cascade_sets_auto_completed_on_parent_only() {
        let pool = test_pool().await;

        // Parent + two subtasks.
        insert_task(
            &pool,
            "parent",
            "Parent task",
            "inbox",
            None,
            None,
            None,
            None,
        )
        .await
        .unwrap();
        insert_subtask(&pool, "s1", "Step 1", "parent")
            .await
            .unwrap();
        insert_subtask(&pool, "s2", "Step 2", "parent")
            .await
            .unwrap();

        complete_subtask_and_check_parent(&pool, "s1")
            .await
            .unwrap();
        complete_subtask_and_check_parent(&pool, "s2")
            .await
            .unwrap();

        // Parent auto-closed.
        let parent_auto: i64 =
            sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 'parent'")
                .fetch_one(&pool)
                .await
                .unwrap();
        assert_eq!(
            parent_auto, 1,
            "parent closed by cascade must be auto_completed = 1"
        );

        // Subtasks themselves are manual.
        let s1_auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 's1'")
            .fetch_one(&pool)
            .await
            .unwrap();
        let s2_auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 's2'")
            .fetch_one(&pool)
            .await
            .unwrap();
        assert_eq!(s1_auto, 0, "subtask completion is manual");
        assert_eq!(s2_auto, 0, "subtask completion is manual");
    }

    #[tokio::test]
    async fn uncomplete_clears_auto_completed_on_parent() {
        let pool = test_pool().await;

        insert_task(
            &pool,
            "parent",
            "Parent task",
            "inbox",
            None,
            None,
            None,
            None,
        )
        .await
        .unwrap();
        insert_subtask(&pool, "s1", "Only step", "parent")
            .await
            .unwrap();

        // Cascade closes the parent with auto_completed = 1.
        complete_subtask_and_check_parent(&pool, "s1")
            .await
            .unwrap();

        // Uncompleting the subtask reopens the parent (existing invariant)
        // and must also clear auto_completed on the parent so a later
        // manual re-completion is counted cleanly.
        uncomplete_task(&pool, "s1").await.unwrap();

        let parent_done: i64 = sqlx::query_scalar("SELECT done FROM tasks WHERE id = 'parent'")
            .fetch_one(&pool)
            .await
            .unwrap();
        let parent_auto: i64 =
            sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 'parent'")
                .fetch_one(&pool)
                .await
                .unwrap();

        assert_eq!(parent_done, 0, "parent reopens when child is uncompleted");
        assert_eq!(parent_auto, 0, "auto_completed must clear on reopen");
    }

    #[tokio::test]
    async fn list_recent_completions_returns_n_entries_including_zero_days() {
        let pool = test_pool().await;
        // No completions at all.
        let series = list_recent_completions(&pool, 7).await.unwrap();
        assert_eq!(series.len(), 7, "always returns exactly N entries");
        assert!(series.iter().all(|d| d.count == 0), "all zero for empty DB");

        // Oldest first, newest last. Dates should be strictly increasing.
        for w in series.windows(2) {
            assert!(w[0].day < w[1].day, "series must be oldest-first");
        }
    }

    #[tokio::test]
    async fn list_recent_completions_excludes_auto_cascade_parents() {
        let pool = test_pool().await;
        insert_task(&pool, "p", "Parent", "inbox", None, None, None, None)
            .await
            .unwrap();
        insert_subtask(&pool, "s1", "Step 1", "p").await.unwrap();

        // Manual subtask complete. Cascade auto-closes parent.
        complete_subtask_and_check_parent(&pool, "s1")
            .await
            .unwrap();

        let series = list_recent_completions(&pool, 7).await.unwrap();
        let total: u32 = series.iter().map(|d| d.count).sum();
        assert_eq!(
            total, 1,
            "subtask counts (1), cascade parent excluded. Got series: {series:?}"
        );
    }

    #[tokio::test]
    async fn list_recent_completions_counts_manual_top_level() {
        let pool = test_pool().await;
        insert_task(&pool, "t1", "Task one", "inbox", None, None, None, None)
            .await
            .unwrap();
        complete_task(&pool, "t1").await.unwrap();

        let series = list_recent_completions(&pool, 7).await.unwrap();
        let total: u32 = series.iter().map(|d| d.count).sum();
        assert_eq!(total, 1);
        // Most recent entry is today (local) and holds the 1.
        assert_eq!(series.last().unwrap().count, 1);
    }

    #[tokio::test]
    async fn list_recent_completions_excludes_uncompleted() {
        let pool = test_pool().await;
        insert_task(&pool, "t1", "Task one", "inbox", None, None, None, None)
            .await
            .unwrap();
        complete_task(&pool, "t1").await.unwrap();
        uncomplete_task(&pool, "t1").await.unwrap();

        let series = list_recent_completions(&pool, 7).await.unwrap();
        let total: u32 = series.iter().map(|d| d.count).sum();
        assert_eq!(
            total, 0,
            "uncompleted rows have NULL done_at and must not count"
        );
    }

    #[tokio::test]
    async fn list_recent_completions_uses_local_day_boundary() {
        // Insert two rows with done_at values on either side of local
        // midnight (expressed in UTC, which is how datetime('now') stores).
        // The row whose local-day is "today" must land in today's bucket.
        // The row whose local-day is "yesterday" must land in yesterday's.
        //
        // We avoid hard-coding dates. Compute them at runtime from
        // SQLite so the test is stable across the real clock. The chosen
        // UTC times (2 days ago noon) are safely either side of midnight
        // for any local timezone that matters for this project.
        let pool = test_pool().await;

        // Seed two tasks that we will manipulate done_at on directly.
        insert_task(&pool, "a", "A", "inbox", None, None, None, None)
            .await
            .unwrap();
        insert_task(&pool, "b", "B", "inbox", None, None, None, None)
            .await
            .unwrap();

        // Put row "a" at a done_at that is definitely "2 days ago" in
        // the same local-day spine the query uses. Anchor to the local
        // date 2 days ago, then suffix " 12:00:00" so the bucket survives
        // timezone shifts at the query time (noon is far enough from
        // either day boundary). Phase 8's earlier "-2 days, start of
        // day, +12 hours" form mixed UTC `now` with the local-time spine
        // and drifted to "3 days ago" near UTC midnight; this anchor
        // matches the spine directly. Row "b" stays at the current moment.
        sqlx::query(
            "UPDATE tasks SET done = 1, \
             done_at = datetime(DATE('now', 'localtime', '-2 days') || ' 12:00:00'), \
             auto_completed = 0 WHERE id = 'a'",
        )
        .execute(&pool)
        .await
        .unwrap();

        sqlx::query(
            "UPDATE tasks SET done = 1, done_at = datetime('now'), auto_completed = 0 \
             WHERE id = 'b'",
        )
        .execute(&pool)
        .await
        .unwrap();

        let series = list_recent_completions(&pool, 7).await.unwrap();
        assert_eq!(series.len(), 7);

        // "today" is the last entry. "2 days ago" is 2 positions before
        // the last entry (0-indexed from end: -1 today, -2 yesterday,
        // -3 two days ago).
        let today_count = series.last().unwrap().count;
        let two_days_ago_count = series[series.len() - 3].count;
        assert_eq!(today_count, 1, "row B is today: {series:?}");
        assert_eq!(two_days_ago_count, 1, "row A is 2 days ago: {series:?}");

        let total: u32 = series.iter().map(|d| d.count).sum();
        assert_eq!(total, 2, "exactly two completions across the window");
    }

    #[tokio::test]
    async fn init_readonly_rejects_writes_and_serves_reads() {
        let dir = std::env::temp_dir().join(format!("lumotia-storage-ro-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("ro.db");
        let _ = std::fs::remove_file(&path);

        // Seed via the writable path so migrations run.
        let writable = init(&path).await.unwrap();
        insert_transcript(
            &writable,
            &InsertTranscriptParams {
                id: "ro1",
                text: "seed",
                source: "microphone",
                profile_id: crate::DEFAULT_PROFILE_ID,
                title: None,
                audio_path: None,
                duration: 0.0,
                engine: None,
                model_id: None,
                inference_ms: None,
                sample_rate: None,
                audio_channels: None,
                format_mode: None,
                remove_fillers: false,
                british_english: false,
                anti_hallucination: false,
            },
        )
        .await
        .unwrap();
        writable.close().await;

        // Reopen read-only.
        let ro = init_readonly(&path).await.expect("read-only open");
        assert_eq!(count_transcripts(&ro).await.unwrap(), 1, "reads must work");

        let write_attempt = sqlx::query("DELETE FROM transcripts WHERE id = 'ro1'")
            .execute(&ro)
            .await;
        assert!(
            write_attempt.is_err(),
            "writes must be rejected on the read-only pool",
        );

        ro.close().await;
        let _ = std::fs::remove_file(&path);
        let _ = std::fs::remove_dir(&dir);
    }

    #[tokio::test]
    async fn init_readonly_fails_when_db_missing() {
        let path = std::env::temp_dir().join(format!(
            "lumotia-storage-ro-missing-{}.db",
            std::process::id()
        ));
        let _ = std::fs::remove_file(&path);
        assert!(
            init_readonly(&path).await.is_err(),
            "must fail when DB file does not exist (no create_if_missing)",
        );
    }

    #[tokio::test]
    async fn prune_error_log_removes_old_rows_only() {
        let pool = test_pool().await;

        // Three rows: one from now, one 30 days old, one 200 days old.
        sqlx::query(
            "INSERT INTO error_log (timestamp, context, error_code, message) VALUES \
             (datetime('now'), 'recent', NULL, 'now'), \
             (datetime('now', '-30 days'), 'older', NULL, '30d'), \
             (datetime('now', '-200 days'), 'oldest', NULL, '200d')",
        )
        .execute(&pool)
        .await
        .unwrap();

        // Default 90-day retention removes only the 200-day row.
        let removed = prune_error_log(&pool, 90).await.unwrap();
        assert_eq!(removed, 1, "only the 200-day row should be pruned");

        let remaining: Vec<String> =
            sqlx::query_scalar("SELECT context FROM error_log ORDER BY id ASC")
                .fetch_all(&pool)
                .await
                .unwrap();
        assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]);

        // A 14-day window prunes the 30-day row too.
        let removed = prune_error_log(&pool, 14).await.unwrap();
        assert_eq!(removed, 1);

        let remaining: Vec<String> = sqlx::query_scalar("SELECT context FROM error_log")
            .fetch_all(&pool)
            .await
            .unwrap();
        assert_eq!(remaining, vec!["recent".to_string()]);
    }

    #[tokio::test]
    async fn migrate_legacy_setting_keys_renames_lone_magnotia_rows() {
        let pool = test_pool().await;
        set_setting(&pool, "magnotia_preferences", "blob-A")
            .await
            .unwrap();
        set_setting(&pool, "magnotia_morning_triage_last_shown", "2026-05-12")
            .await
            .unwrap();

        let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap();

        assert_eq!(renamed, 2);
        assert_eq!(deleted, 0);
        assert_eq!(
            get_setting(&pool, "lumotia_preferences")
                .await
                .unwrap()
                .as_deref(),
            Some("blob-A")
        );
        assert_eq!(
            get_setting(&pool, "magnotia_preferences").await.unwrap(),
            None
        );
        assert_eq!(
            get_setting(&pool, "lumotia_morning_triage_last_shown")
                .await
                .unwrap()
                .as_deref(),
            Some("2026-05-12")
        );
    }

    #[tokio::test]
    async fn migrate_legacy_setting_keys_deletes_orphans_when_both_present() {
        let pool = test_pool().await;
        set_setting(&pool, "magnotia_preferences", "legacy-blob")
            .await
            .unwrap();
        set_setting(&pool, "lumotia_preferences", "current-blob")
            .await
            .unwrap();

        let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap();

        assert_eq!(renamed, 0);
        assert_eq!(deleted, 1);
        assert_eq!(
            get_setting(&pool, "lumotia_preferences")
                .await
                .unwrap()
                .as_deref(),
            Some("current-blob"),
            "lumotia row preserved verbatim"
        );
        assert_eq!(
            get_setting(&pool, "magnotia_preferences").await.unwrap(),
            None,
            "orphan lumotia row deleted"
        );
    }

    #[tokio::test]
    async fn migrate_legacy_setting_keys_no_op_when_no_magnotia_rows() {
        let pool = test_pool().await;
        set_setting(&pool, "lumotia_preferences", "blob")
            .await
            .unwrap();

        let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap();

        assert_eq!(renamed, 0);
        assert_eq!(deleted, 0);
    }

    #[tokio::test]
    async fn migrate_legacy_setting_keys_is_idempotent() {
        let pool = test_pool().await;
        set_setting(&pool, "magnotia_preferences", "blob")
            .await
            .unwrap();

        let first = migrate_legacy_setting_keys(&pool).await.unwrap();
        let second = migrate_legacy_setting_keys(&pool).await.unwrap();

        assert_eq!(first, (1, 0));
        assert_eq!(second, (0, 0));
    }

    fn minimal_transcript(
        id: &'static str,
        audio_path: Option<&'static str>,
    ) -> InsertTranscriptParams<'static> {
        InsertTranscriptParams {
            id,
            text: "soft-delete fixture",
            source: "microphone",
            profile_id: crate::DEFAULT_PROFILE_ID,
            title: None,
            audio_path,
            duration: 1.0,
            engine: None,
            model_id: None,
            inference_ms: None,
            sample_rate: None,
            audio_channels: None,
            format_mode: None,
            remove_fillers: false,
            british_english: true,
            anti_hallucination: false,
        }
    }

    #[tokio::test]
    async fn delete_transcript_soft_deletes() {
        // Rev-2 contract: delete_transcript flips `deleted_at`, does not
        // remove the row. A direct SELECT bypassing the deleted_at filter
        // must still find the row.
        let pool = test_pool().await;
        insert_transcript(&pool, &minimal_transcript("t-soft", None))
            .await
            .unwrap();

        delete_transcript(&pool, "t-soft").await.unwrap();

        let deleted_at: Option<String> =
            sqlx::query_scalar("SELECT deleted_at FROM transcripts WHERE id = ?")
                .bind("t-soft")
                .fetch_one(&pool)
                .await
                .unwrap();
        assert!(
            deleted_at.is_some(),
            "row should still exist with deleted_at set"
        );
    }

    #[tokio::test]
    async fn delete_transcript_removes_audio_file() {
        // Rev-3 contract: best-effort fs::remove_file fires when the
        // soft-delete actually flipped a row. A repeat delete is a
        // no-op and does NOT fail when the file is already gone.
        let pool = test_pool().await;
        let tmp = std::env::temp_dir().join(format!("lumotia-test-{}.wav", std::process::id()));
        std::fs::write(&tmp, b"fake wav").unwrap();
        let path_owned = tmp.to_string_lossy().to_string();
        let path_static: &'static str = Box::leak(path_owned.into_boxed_str());

        insert_transcript(&pool, &minimal_transcript("t-audio", Some(path_static)))
            .await
            .unwrap();
        assert!(tmp.exists(), "fixture file should exist pre-delete");

        delete_transcript(&pool, "t-audio").await.unwrap();
        assert!(
            !tmp.exists(),
            "audio file should be removed by delete_transcript"
        );

        // Repeat delete must not surface an error even though both the
        // soft-delete UPDATE is a no-op AND the audio file is already gone.
        delete_transcript(&pool, "t-audio").await.unwrap();
    }

    #[tokio::test]
    async fn list_transcripts_excludes_soft_deleted() {
        // Rev-2 list contract: soft-deleted rows are invisible to the
        // regular list path; the trash view is the inverse.
        let pool = test_pool().await;
        insert_transcript(&pool, &minimal_transcript("t-live", None))
            .await
            .unwrap();
        insert_transcript(&pool, &minimal_transcript("t-trashed", None))
            .await
            .unwrap();

        delete_transcript(&pool, "t-trashed").await.unwrap();

        let live = list_transcripts(&pool, 100).await.unwrap();
        assert_eq!(live.len(), 1);
        assert_eq!(live[0].id, "t-live");

        let trashed = list_trashed_transcripts(&pool, 100, 0).await.unwrap();
        assert_eq!(trashed.len(), 1);
        assert_eq!(trashed[0].id, "t-trashed");

        let total = count_transcripts(&pool).await.unwrap();
        assert_eq!(total, 1, "count_transcripts excludes the trash");

        // Restore brings the row back into the live list.
        restore_transcript(&pool, "t-trashed").await.unwrap();
        let after_restore = list_transcripts(&pool, 100).await.unwrap();
        assert_eq!(after_restore.len(), 2);
    }

    #[tokio::test]
    async fn purge_deleted_transcripts_hard_deletes_old() {
        // Retention contract: purge_deleted_transcripts hard-removes rows
        // whose deleted_at is older than `older_than_days`. Newer trash
        // rows are preserved.
        let pool = test_pool().await;
        insert_transcript(&pool, &minimal_transcript("t-old", None))
            .await
            .unwrap();
        insert_transcript(&pool, &minimal_transcript("t-new", None))
            .await
            .unwrap();
        delete_transcript(&pool, "t-old").await.unwrap();
        delete_transcript(&pool, "t-new").await.unwrap();

        // Backdate t-old past the 30-day window. t-new keeps "now".
        sqlx::query("UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?")
            .bind("t-old")
            .execute(&pool)
            .await
            .unwrap();

        let purged = purge_deleted_transcripts(&pool, 30).await.unwrap();
        assert_eq!(purged, 1, "only t-old should be hard-deleted");

        let old_exists: Option<String> =
            sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?")
                .bind("t-old")
                .fetch_optional(&pool)
                .await
                .unwrap();
        assert!(old_exists.is_none(), "t-old should be hard-gone");

        let new_exists: Option<String> =
            sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?")
                .bind("t-new")
                .fetch_optional(&pool)
                .await
                .unwrap();
        assert!(
            new_exists.is_some(),
            "t-new still inside the retention window"
        );
    }

    /// Phase B.4 audit regression (2026-05-14). Asserts the
    /// `DELETE … RETURNING` form correctly couples row-removal with the
    /// audio-cleanup loop: only rows the DELETE actually affected get
    /// their audio file removed, and rows outside the retention window
    /// are untouched.
    ///
    /// The race we fixed (restore between SELECT and DELETE in the old
    /// two-statement form) requires fault injection between the two
    /// statements to exercise deterministically. The atomic single-
    /// statement form makes that race structurally impossible. We test
    /// the structural property: an in-retention trashed row with its
    /// audio file on disk survives purge with the audio intact, while a
    /// past-retention trashed row is hard-deleted with audio removed.
    #[tokio::test]
    async fn purge_audio_cleanup_only_fires_for_hard_deleted_rows() {
        let pool = test_pool().await;

        let tmpdir = tempfile::tempdir().expect("tmpdir");
        let old_audio = tmpdir.path().join("old.wav");
        let recent_audio = tmpdir.path().join("recent.wav");
        std::fs::write(&old_audio, b"old wav").unwrap();
        std::fs::write(&recent_audio, b"recent wav").unwrap();

        let old_path_static: &'static str =
            Box::leak(old_audio.to_string_lossy().to_string().into_boxed_str());
        let recent_path_static: &'static str =
            Box::leak(recent_audio.to_string_lossy().to_string().into_boxed_str());

        insert_transcript(
            &pool,
            &minimal_transcript("t-purge-old", Some(old_path_static)),
        )
        .await
        .unwrap();
        insert_transcript(
            &pool,
            &minimal_transcript("t-purge-recent", Some(recent_path_static)),
        )
        .await
        .unwrap();

        // Manually mark both trashed without going through delete_transcript
        // (which would best-effort-remove the audio files itself). Old row
        // is backdated past the 30-day retention; recent row is fresh.
        sqlx::query("UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?")
            .bind("t-purge-old")
            .execute(&pool)
            .await
            .unwrap();
        sqlx::query("UPDATE transcripts SET deleted_at = datetime('now') WHERE id = ?")
            .bind("t-purge-recent")
            .execute(&pool)
            .await
            .unwrap();

        let purged = purge_deleted_transcripts(&pool, 30).await.unwrap();
        assert_eq!(purged, 1, "only t-purge-old is past retention");

        let old_exists: Option<String> =
            sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?")
                .bind("t-purge-old")
                .fetch_optional(&pool)
                .await
                .unwrap();
        assert!(old_exists.is_none(), "t-purge-old must be hard-deleted");
        assert!(
            !old_audio.exists(),
            "audio for hard-deleted row must be removed by purge"
        );

        let recent_exists: Option<String> =
            sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?")
                .bind("t-purge-recent")
                .fetch_optional(&pool)
                .await
                .unwrap();
        assert!(
            recent_exists.is_some(),
            "t-purge-recent within retention must survive"
        );
        assert!(
            recent_audio.exists(),
            "audio for surviving in-retention row must NOT be removed by purge"
        );
    }
}
