use std::{sync::Arc, time::Duration};

use chrono::Duration as ChronoDuration;
use futures::future::try_join_all;
use state_machines::state_machine;
use surrealdb::sql::Datetime as SurrealDatetime;
use uuid::Uuid;

use crate::{error::AppError, storage::db::SurrealDbClient, stored_object};

use super::ingestion_payload::IngestionPayload;

pub const MAX_ATTEMPTS: u32 = 3;
pub const DEFAULT_LEASE_SECS: i64 = 300;
pub const DEFAULT_PRIORITY: i32 = 0;

#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub enum TaskState {
    #[serde(rename = "Pending")]
    #[default]
    Pending,
    #[serde(rename = "Reserved")]
    Reserved,
    #[serde(rename = "Processing")]
    Processing,
    #[serde(rename = "Succeeded")]
    Succeeded,
    #[serde(rename = "Failed")]
    Failed,
    #[serde(rename = "Cancelled")]
    Cancelled,
    #[serde(rename = "DeadLetter")]
    DeadLetter,
}

impl TaskState {
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            TaskState::Pending => "Pending",
            TaskState::Reserved => "Reserved",
            TaskState::Processing => "Processing",
            TaskState::Succeeded => "Succeeded",
            TaskState::Failed => "Failed",
            TaskState::Cancelled => "Cancelled",
            TaskState::DeadLetter => "DeadLetter",
        }
    }

    #[must_use]
    pub fn is_terminal(&self) -> bool {
        matches!(
            self,
            TaskState::Succeeded | TaskState::Cancelled | TaskState::DeadLetter
        )
    }

    #[must_use]
    pub fn display_label(&self) -> &'static str {
        match self {
            TaskState::Pending => "Pending",
            TaskState::Reserved => "Reserved",
            TaskState::Processing => "Processing",
            TaskState::Succeeded => "Completed",
            TaskState::Failed => "Retrying",
            TaskState::Cancelled => "Cancelled",
            TaskState::DeadLetter => "Dead Letter",
        }
    }
}

/// Information about an error that occurred during task processing.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
pub struct TaskErrorInfo {
    /// Machine-readable error code (e.g., `"pipeline_error"`).
    pub code: Option<String>,
    /// Human-readable error description.
    pub message: String,
}

/// Internal events that drive the task state machine transitions.
#[derive(Debug, Clone, Copy)]
enum TaskTransition {
    StartProcessing,
    Succeed,
    Fail,
    Cancel,
    DeadLetter,
    Release,
}

impl TaskTransition {
    fn as_str(self) -> &'static str {
        match self {
            TaskTransition::StartProcessing => "start_processing",
            TaskTransition::Succeed => "succeed",
            TaskTransition::Fail => "fail",
            TaskTransition::Cancel => "cancel",
            TaskTransition::DeadLetter => "deadletter",
            TaskTransition::Release => "release",
        }
    }
}

mod lifecycle {
    use super::state_machine;

    state_machine! {
        name: TaskLifecycleMachine,
        initial: Pending,
        states: [Pending, Reserved, Processing, Succeeded, Failed, Cancelled, DeadLetter],
        events {
            reserve {
                transition: { from: Pending, to: Reserved }
                transition: { from: Failed, to: Reserved }
            }
            start_processing {
                transition: { from: Reserved, to: Processing }
            }
            succeed {
                transition: { from: Processing, to: Succeeded }
            }
            fail {
                transition: { from: Processing, to: Failed }
            }
            cancel {
                transition: { from: Pending, to: Cancelled }
                transition: { from: Reserved, to: Cancelled }
                transition: { from: Processing, to: Cancelled }
            }
            deadletter {
                transition: { from: Failed, to: DeadLetter }
            }
            release {
                transition: { from: Reserved, to: Pending }
            }
        }
    }

    pub(super) fn pending() -> TaskLifecycleMachine<(), Pending> {
        TaskLifecycleMachine::new(())
    }
}

fn invalid_transition(state: TaskState, event: TaskTransition) -> AppError {
    AppError::Validation(format!(
        "invalid task transition: {} -> {}",
        state.as_str(),
        event.as_str()
    ))
}

fn worker_id_for_bind(worker_id: Option<&String>) -> String {
    worker_id.cloned().unwrap_or_default()
}

stored_object!(IngestionTask, "ingestion_task", {
    content: IngestionPayload,
    state: TaskState,
    user_id: String,
    attempts: u32,
    max_attempts: u32,
    #[serde(serialize_with = "serialize_datetime", deserialize_with = "deserialize_datetime")]
    scheduled_at: chrono::DateTime<chrono::Utc>,
    #[serde(
        serialize_with = "serialize_option_datetime",
        deserialize_with = "deserialize_option_datetime",
        default
    )]
    locked_at: Option<chrono::DateTime<chrono::Utc>>,
    lease_duration_secs: i64,
    worker_id: Option<String>,
    error_code: Option<String>,
    error_message: Option<String>,
    #[serde(
        serialize_with = "serialize_option_datetime",
        deserialize_with = "deserialize_option_datetime",
        default
    )]
    last_error_at: Option<chrono::DateTime<chrono::Utc>>,
    priority: i32
});

impl IngestionTask {
    #[must_use]
    pub fn new(content: IngestionPayload, user_id: String) -> Self {
        let now = chrono::Utc::now();

        Self {
            id: Uuid::new_v4().to_string(),
            content,
            state: TaskState::Pending,
            user_id,
            attempts: 0,
            max_attempts: MAX_ATTEMPTS,
            scheduled_at: now,
            locked_at: None,
            lease_duration_secs: DEFAULT_LEASE_SECS,
            worker_id: None,
            error_code: None,
            error_message: None,
            last_error_at: None,
            priority: DEFAULT_PRIORITY,
            created_at: now,
            updated_at: now,
        }
    }

    #[must_use]
    pub fn can_retry(&self) -> bool {
        self.attempts < self.max_attempts
    }

    /// Moves the payload out of the task, leaving an empty placeholder behind.
    ///
    /// The task's `content` is only needed while driving the pipeline; the
    /// terminal `user_id`, `state`, and bookkeeping fields are stored separately,
    /// so replacing it with the default placeholder avoids cloning large payloads.
    #[must_use]
    pub fn take_content(&mut self) -> IngestionPayload {
        std::mem::take(&mut self.content)
    }

    #[must_use]
    pub fn lease_duration(&self) -> Duration {
        Duration::from_secs(u64::try_from(self.lease_duration_secs.max(0)).unwrap_or(0))
    }

    /// Create a new task and immediately persist it to the database.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Database` if the store operation fails.
    /// Returns `AppError::internal` if the database returns no stored record.
    pub async fn create_and_add_to_db(
        content: IngestionPayload,
        user_id: impl AsRef<str>,
        db: &SurrealDbClient,
    ) -> Result<IngestionTask, AppError> {
        let task = Self::new(content, user_id.as_ref().to_string());
        db.store_item(task)
            .await?
            .ok_or_else(|| AppError::internal("ingestion task store returned no record"))
    }

    /// Create and persist multiple tasks concurrently (one `CREATE` per payload).
    ///
    /// Use this when ingest produces several payloads (files plus URL/text). For a
    /// single payload, call [`Self::create_and_add_to_db`] instead.
    ///
    /// # Errors
    ///
    /// Returns the first [`AppError`] from any failed store, same as [`try_join_all`].
    pub async fn create_all_and_add_to_db(
        contents: Vec<IngestionPayload>,
        user_id: impl AsRef<str>,
        db: &SurrealDbClient,
    ) -> Result<Vec<IngestionTask>, AppError> {
        if contents.is_empty() {
            return Ok(Vec::new());
        }

        let user_id = Arc::new(user_id.as_ref().to_string());
        let db = db.clone();

        try_join_all(contents.into_iter().map(|content| {
            let user_id = Arc::clone(&user_id);
            let db = db.clone();
            async move { Self::create_and_add_to_db(content, user_id.as_ref(), &db).await }
        }))
        .await
    }

    /// Claim the next ready task for processing.
    ///
    /// Atomically reserves a task by transitioning it from a candidate state to `Reserved`.
    /// Returns `Ok(None)` if no task is ready to claim.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Database` if the update query fails.
    pub async fn claim_next_ready(
        db: &SurrealDbClient,
        worker_id: &str,
        now: chrono::DateTime<chrono::Utc>,
        lease_duration: Duration,
    ) -> Result<Option<IngestionTask>, AppError> {
        const CLAIM_QUERY: &str = r#"
            UPDATE (
                SELECT * FROM type::table($table)
                WHERE state IN $candidate_states
                  AND scheduled_at <= $now
                  AND (
                        attempts < max_attempts
                        OR state IN $sticky_states
                  )
                  AND (
                        locked_at = NONE
                        OR time::unix($now) - time::unix(locked_at) >= lease_duration_secs
                  )
                ORDER BY priority DESC, scheduled_at ASC, created_at ASC
                LIMIT 1
            )
            SET state = $reserved_state,
                attempts = if state IN $increment_states THEN
                    if attempts + 1 > max_attempts THEN max_attempts ELSE attempts + 1 END
                ELSE
                    attempts
                END,
                locked_at = $now,
                worker_id = $worker_id,
                lease_duration_secs = $lease_secs,
                updated_at = $now
            RETURN *;
        "#;

        debug_assert!(lifecycle::pending().reserve().is_ok());
        debug_assert!(lifecycle::pending().reserve().is_ok_and(|m| {
            m.start_processing()
                .is_ok_and(|m| m.fail().is_ok_and(|m| m.reserve().is_ok()))
        }));

        let mut result = db
            .client
            .query(CLAIM_QUERY)
            .bind(("table", Self::table_name()))
            .bind((
                "candidate_states",
                vec![
                    TaskState::Pending.as_str(),
                    TaskState::Failed.as_str(),
                    TaskState::Reserved.as_str(),
                    TaskState::Processing.as_str(),
                ],
            ))
            .bind((
                "sticky_states",
                vec![TaskState::Reserved.as_str(), TaskState::Processing.as_str()],
            ))
            .bind((
                "increment_states",
                vec![TaskState::Pending.as_str(), TaskState::Failed.as_str()],
            ))
            .bind(("reserved_state", TaskState::Reserved.as_str()))
            .bind(("now", SurrealDatetime::from(now)))
            .bind(("worker_id", worker_id.to_string()))
            .bind((
                "lease_secs",
                i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX),
            ))
            .await?;

        let task: Option<IngestionTask> = result.take(0)?;
        Ok(task)
    }

    /// Transition this task from `Reserved` to `Processing`.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Validation` if the task is not in `Reserved` state
    /// or belongs to a different worker. Returns `AppError::Database` on DB failure.
    pub async fn mark_processing(&self, db: &SurrealDbClient) -> Result<IngestionTask, AppError> {
        const START_PROCESSING_QUERY: &str = r#"
            UPDATE type::thing($table, $id)
            SET state = $processing,
                updated_at = $now,
                locked_at = $now
            WHERE state = $reserved AND worker_id = $worker_id
            RETURN *;
        "#;

        let now = chrono::Utc::now();
        let worker_id = worker_id_for_bind(self.worker_id.as_ref());
        let mut result = db
            .client
            .query(START_PROCESSING_QUERY)
            .bind(("table", Self::table_name()))
            .bind(("id", self.id.clone()))
            .bind(("processing", TaskState::Processing.as_str()))
            .bind(("reserved", TaskState::Reserved.as_str()))
            .bind(("now", SurrealDatetime::from(now)))
            .bind(("worker_id", worker_id))
            .await?;

        let updated: Option<IngestionTask> = result.take(0)?;
        updated.ok_or_else(|| invalid_transition(self.state, TaskTransition::StartProcessing))
    }

    /// Transition this task from `Processing` to `Succeeded`.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Validation` if the task is not in `Processing` state
    /// or belongs to a different worker. Returns `AppError::Database` on DB failure.
    pub async fn mark_succeeded(&self, db: &SurrealDbClient) -> Result<IngestionTask, AppError> {
        const COMPLETE_QUERY: &str = r#"
            UPDATE type::thing($table, $id)
            SET state = $succeeded,
                updated_at = $now,
                locked_at = NONE,
                worker_id = NONE,
                scheduled_at = $now,
                error_code = NONE,
                error_message = NONE,
                last_error_at = NONE
            WHERE state = $processing AND worker_id = $worker_id
            RETURN *;
        "#;

        let now = chrono::Utc::now();
        let worker_id = worker_id_for_bind(self.worker_id.as_ref());
        let mut result = db
            .client
            .query(COMPLETE_QUERY)
            .bind(("table", Self::table_name()))
            .bind(("id", self.id.clone()))
            .bind(("succeeded", TaskState::Succeeded.as_str()))
            .bind(("processing", TaskState::Processing.as_str()))
            .bind(("now", SurrealDatetime::from(now)))
            .bind(("worker_id", worker_id))
            .await?;

        let updated: Option<IngestionTask> = result.take(0)?;
        updated.ok_or_else(|| invalid_transition(self.state, TaskTransition::Succeed))
    }

    /// Transition this task from `Processing` to `Failed`.
    ///
    /// The task will be rescheduled for retry after `retry_delay`.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Validation` if the task is not in `Processing` state
    /// or belongs to a different worker. Returns `AppError::Database` on DB failure.
    pub async fn mark_failed(
        &self,
        error: TaskErrorInfo,
        retry_delay: Duration,
        db: &SurrealDbClient,
    ) -> Result<IngestionTask, AppError> {
        const FAIL_QUERY: &str = r#"
            UPDATE type::thing($table, $id)
            SET state = $failed,
                updated_at = $now,
                locked_at = NONE,
                worker_id = NONE,
                scheduled_at = $retry_at,
                error_code = $error_code,
                error_message = $error_message,
                last_error_at = $now
            WHERE state = $processing AND worker_id = $worker_id
            RETURN *;
        "#;

        let now = chrono::Utc::now();
        let retry_at = now
            .checked_add_signed(
                ChronoDuration::from_std(retry_delay)
                    .unwrap_or_else(|_| ChronoDuration::seconds(30)),
            )
            .unwrap_or(now);

        let worker_id = worker_id_for_bind(self.worker_id.as_ref());
        let mut result = db
            .client
            .query(FAIL_QUERY)
            .bind(("table", Self::table_name()))
            .bind(("id", self.id.clone()))
            .bind(("failed", TaskState::Failed.as_str()))
            .bind(("processing", TaskState::Processing.as_str()))
            .bind(("now", SurrealDatetime::from(now)))
            .bind(("retry_at", SurrealDatetime::from(retry_at)))
            .bind(("error_code", error.code.clone()))
            .bind(("error_message", error.message.clone()))
            .bind(("worker_id", worker_id))
            .await?;

        let updated: Option<IngestionTask> = result.take(0)?;
        updated.ok_or_else(|| invalid_transition(self.state, TaskTransition::Fail))
    }

    /// Transition this task from `Failed` to `DeadLetter`.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Validation` if the task is not in `Failed` state.
    /// Returns `AppError::Database` on DB failure.
    pub async fn mark_dead_letter(
        &self,
        error: TaskErrorInfo,
        db: &SurrealDbClient,
    ) -> Result<IngestionTask, AppError> {
        const DEAD_LETTER_QUERY: &str = r#"
            UPDATE type::thing($table, $id)
            SET state = $dead,
                updated_at = $now,
                locked_at = NONE,
                worker_id = NONE,
                scheduled_at = $now,
                error_code = $error_code,
                error_message = $error_message,
                last_error_at = $now
            WHERE state = $failed
            RETURN *;
        "#;

        let now = chrono::Utc::now();
        let mut result = db
            .client
            .query(DEAD_LETTER_QUERY)
            .bind(("table", Self::table_name()))
            .bind(("id", self.id.clone()))
            .bind(("dead", TaskState::DeadLetter.as_str()))
            .bind(("failed", TaskState::Failed.as_str()))
            .bind(("now", SurrealDatetime::from(now)))
            .bind(("error_code", error.code.clone()))
            .bind(("error_message", error.message.clone()))
            .await?;

        let updated: Option<IngestionTask> = result.take(0)?;
        updated.ok_or_else(|| invalid_transition(self.state, TaskTransition::DeadLetter))
    }

    /// Transition this task to `Cancelled` from any non-terminal state.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Validation` if the task is in a terminal state.
    /// Returns `AppError::Database` on DB failure.
    pub async fn mark_cancelled(&self, db: &SurrealDbClient) -> Result<IngestionTask, AppError> {
        const CANCEL_QUERY: &str = r#"
            UPDATE type::thing($table, $id)
            SET state = $cancelled,
                updated_at = $now,
                locked_at = NONE,
                worker_id = NONE
            WHERE state IN $allow_states
            RETURN *;
        "#;

        let now = chrono::Utc::now();
        let mut result = db
            .client
            .query(CANCEL_QUERY)
            .bind(("table", Self::table_name()))
            .bind(("id", self.id.clone()))
            .bind(("cancelled", TaskState::Cancelled.as_str()))
            .bind((
                "allow_states",
                vec![
                    TaskState::Pending.as_str(),
                    TaskState::Reserved.as_str(),
                    TaskState::Processing.as_str(),
                ],
            ))
            .bind(("now", SurrealDatetime::from(now)))
            .await?;

        let updated: Option<IngestionTask> = result.take(0)?;
        updated.ok_or_else(|| invalid_transition(self.state, TaskTransition::Cancel))
    }

    /// Release a reserved task back to `Pending` state.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Validation` if the task is not in `Reserved` state.
    /// Returns `AppError::Database` on DB failure.
    pub async fn release(&self, db: &SurrealDbClient) -> Result<IngestionTask, AppError> {
        const RELEASE_QUERY: &str = r#"
            UPDATE type::thing($table, $id)
            SET state = $pending,
                updated_at = $now,
                locked_at = NONE,
                worker_id = NONE
            WHERE state = $reserved
            RETURN *;
        "#;

        let now = chrono::Utc::now();
        let mut result = db
            .client
            .query(RELEASE_QUERY)
            .bind(("table", Self::table_name()))
            .bind(("id", self.id.clone()))
            .bind(("pending", TaskState::Pending.as_str()))
            .bind(("reserved", TaskState::Reserved.as_str()))
            .bind(("now", SurrealDatetime::from(now)))
            .await?;

        let updated: Option<IngestionTask> = result.take(0)?;
        updated.ok_or_else(|| invalid_transition(self.state, TaskTransition::Release))
    }

    /// Retrieve all non-terminal tasks across active states.
    ///
    /// # Errors
    ///
    /// Returns `AppError::Database` if the query fails.
    pub async fn get_unfinished_tasks(
        db: &SurrealDbClient,
    ) -> Result<Vec<IngestionTask>, AppError> {
        let tasks: Vec<IngestionTask> = db
            .query(
                "SELECT * FROM type::table($table)
                 WHERE state IN $active_states
                 ORDER BY scheduled_at ASC, created_at ASC",
            )
            .bind(("table", Self::table_name()))
            .bind((
                "active_states",
                vec![
                    TaskState::Pending.as_str(),
                    TaskState::Reserved.as_str(),
                    TaskState::Processing.as_str(),
                    TaskState::Failed.as_str(),
                ],
            ))
            .await?
            .take(0)?;

        Ok(tasks)
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used, clippy::must_use_candidate)]
    use anyhow::{self, Context};

    use super::*;
    use crate::storage::types::ingestion_payload::IngestionPayload;
    use crate::test_utils::setup_test_db;

    fn create_payload(user_id: &str) -> IngestionPayload {
        IngestionPayload::Text {
            text: "Test content".to_string(),
            context: "Test context".to_string(),
            category: "Test category".to_string(),
            user_id: user_id.to_string(),
        }
    }

    async fn memory_db() -> anyhow::Result<SurrealDbClient> {
        setup_test_db().await
    }

    #[tokio::test]
    async fn test_new_task_defaults() -> anyhow::Result<()> {
        let user_id = "user123";
        let payload = create_payload(user_id);
        let task = IngestionTask::new(payload.clone(), user_id.to_string());

        assert_eq!(task.user_id, user_id);
        assert_eq!(task.content, payload);
        assert_eq!(task.state, TaskState::Pending);
        assert_eq!(task.attempts, 0);
        assert_eq!(task.max_attempts, MAX_ATTEMPTS);
        assert!(task.locked_at.is_none());
        assert!(task.worker_id.is_none());
        Ok(())
    }

    #[test]
    fn test_take_content_moves_payload_and_leaves_default() {
        let user_id = "user123";
        let payload = create_payload(user_id);
        let mut task = IngestionTask::new(payload.clone(), user_id.to_string());

        let taken = task.take_content();

        assert_eq!(taken, payload);
        assert_eq!(task.content, IngestionPayload::default());
    }

    #[tokio::test]
    async fn test_create_all_and_add_to_db_empty() -> anyhow::Result<()> {
        let db = memory_db().await?;
        let tasks = IngestionTask::create_all_and_add_to_db(vec![], "user123", &db).await?;
        assert!(tasks.is_empty());
        Ok(())
    }

    #[tokio::test]
    async fn test_create_all_and_add_to_db_stores_multiple() -> anyhow::Result<()> {
        let db = memory_db().await?;
        let user_id = "user123";
        let payloads = vec![
            create_payload(user_id),
            IngestionPayload::Text {
                text: "second payload".to_string(),
                context: "ctx".to_string(),
                category: "cat".to_string(),
                user_id: user_id.to_string(),
            },
        ];

        let created = IngestionTask::create_all_and_add_to_db(payloads, user_id, &db).await?;

        assert_eq!(created.len(), 2);
        let first = created.first().expect("expected first task");
        let second = created.get(1).expect("expected second task");
        assert_ne!(first.id, second.id);

        for task in &created {
            let stored: Option<IngestionTask> = db.get_item::<IngestionTask>(&task.id).await?;
            let stored = stored.with_context(|| format!("task {} should exist", task.id))?;
            assert_eq!(stored.id, task.id);
            assert_eq!(stored.state, TaskState::Pending);
            assert_eq!(stored.user_id, user_id);
        }
        Ok(())
    }

    #[tokio::test]
    async fn test_create_and_store_task() -> anyhow::Result<()> {
        let db = memory_db().await?;
        let user_id = "user123";
        let payload = create_payload(user_id);

        let created =
            IngestionTask::create_and_add_to_db(payload.clone(), user_id.to_string(), &db)
                .await
                .with_context(|| "store".to_string())?;

        let stored: Option<IngestionTask> = db
            .get_item::<IngestionTask>(&created.id)
            .await
            .with_context(|| "fetch".to_string())?;

        let stored = stored.with_context(|| "task exists".to_string())?;
        assert_eq!(stored.id, created.id);
        assert_eq!(stored.state, TaskState::Pending);
        assert_eq!(stored.attempts, 0);
        Ok(())
    }

    #[tokio::test]
    async fn test_claim_and_transition() -> anyhow::Result<()> {
        let db = memory_db().await?;
        let user_id = "user123";
        let payload = create_payload(user_id);
        let task = IngestionTask::new(payload, user_id.to_string());
        db.store_item(task.clone())
            .await
            .with_context(|| "store".to_string())?;

        let worker_id = "worker-1";
        let now = chrono::Utc::now();
        let claimed = IngestionTask::claim_next_ready(&db, worker_id, now, Duration::from_secs(60))
            .await
            .with_context(|| "claim".to_string())?
            .with_context(|| "task claimed".to_string())?;

        assert_eq!(claimed.state, TaskState::Reserved);
        assert_eq!(claimed.worker_id.as_deref(), Some(worker_id));

        let processing = claimed
            .mark_processing(&db)
            .await
            .with_context(|| "processing".to_string())?;
        assert_eq!(processing.state, TaskState::Processing);

        let succeeded = processing
            .mark_succeeded(&db)
            .await
            .with_context(|| "succeeded".to_string())?;
        assert_eq!(succeeded.state, TaskState::Succeeded);
        assert!(succeeded.worker_id.is_none());
        assert!(succeeded.locked_at.is_none());
        Ok(())
    }

    #[tokio::test]
    async fn test_fail_and_dead_letter() -> anyhow::Result<()> {
        let db = memory_db().await?;
        let user_id = "user123";
        let payload = create_payload(user_id);
        let task = IngestionTask::new(payload, user_id.to_string());
        db.store_item(task.clone())
            .await
            .with_context(|| "store".to_string())?;

        let worker_id = "worker-dead";
        let now = chrono::Utc::now();
        let claimed = IngestionTask::claim_next_ready(&db, worker_id, now, Duration::from_secs(60))
            .await
            .with_context(|| "claim".to_string())?
            .with_context(|| "claimed".to_string())?;

        let processing = claimed
            .mark_processing(&db)
            .await
            .with_context(|| "processing".to_string())?;

        let error_info = TaskErrorInfo {
            code: Some("pipeline_error".into()),
            message: "failed".into(),
        };

        let failed = processing
            .mark_failed(error_info.clone(), Duration::from_secs(30), &db)
            .await
            .with_context(|| "failed update".to_string())?;
        assert_eq!(failed.state, TaskState::Failed);
        assert_eq!(failed.error_message.as_deref(), Some("failed"));
        assert!(failed.worker_id.is_none());
        assert!(failed.locked_at.is_none());
        assert!(failed.scheduled_at > now);

        let dead = failed
            .mark_dead_letter(error_info.clone(), &db)
            .await
            .with_context(|| "dead letter".to_string())?;
        assert_eq!(dead.state, TaskState::DeadLetter);
        assert_eq!(dead.error_message.as_deref(), Some("failed"));
        Ok(())
    }

    #[tokio::test]
    async fn test_mark_processing_requires_reservation() -> anyhow::Result<()> {
        let db = memory_db().await?;
        let user_id = "user123";
        let payload = create_payload(user_id);

        let task = IngestionTask::new(payload.clone(), user_id.to_string());
        db.store_item(task.clone())
            .await
            .with_context(|| "store".to_string())?;

        let Err(err) = task.mark_processing(&db).await else {
            anyhow::bail!("processing should fail without reservation")
        };

        match err {
            AppError::Validation(message) => {
                assert!(
                    message.contains("Pending -> start_processing"),
                    "unexpected message: {message}"
                );
            }
            other => anyhow::bail!("expected validation error, got {other:?}"),
        }
        Ok(())
    }

    #[tokio::test]
    async fn test_mark_failed_requires_processing() -> anyhow::Result<()> {
        let db = memory_db().await?;
        let user_id = "user123";
        let payload = create_payload(user_id);

        let task = IngestionTask::new(payload.clone(), user_id.to_string());
        db.store_item(task.clone())
            .await
            .with_context(|| "store".to_string())?;

        let Err(err) = task
            .mark_failed(
                TaskErrorInfo {
                    code: None,
                    message: "boom".into(),
                },
                Duration::from_secs(30),
                &db,
            )
            .await
        else {
            anyhow::bail!("failing should require processing state")
        };

        match err {
            AppError::Validation(message) => {
                assert!(
                    message.contains("Pending -> fail"),
                    "unexpected message: {message}"
                );
            }
            other => anyhow::bail!("expected validation error, got {other:?}"),
        }
        Ok(())
    }

    #[tokio::test]
    async fn test_release_requires_reservation() -> anyhow::Result<()> {
        let db = memory_db().await?;
        let user_id = "user123";
        let payload = create_payload(user_id);

        let task = IngestionTask::new(payload.clone(), user_id.to_string());
        db.store_item(task.clone())
            .await
            .with_context(|| "store".to_string())?;

        let Err(err) = task.release(&db).await else {
            anyhow::bail!("release should require reserved state")
        };

        match err {
            AppError::Validation(message) => {
                assert!(
                    message.contains("Pending -> release"),
                    "unexpected message: {message}"
                );
            }
            other => anyhow::bail!("expected validation error, got {other:?}"),
        }
        Ok(())
    }
}
