//! Persistence for crawled mangas.
//!
//! High-level operations:
//! - [`ensure_source`]: idempotent registration of a source row.
//! - [`upsert_manga_from_source`]: end-to-end "I saw this manga" —
//!   creates or updates the `mangas` row, threads `manga_sources`, and
//!   refreshes authors/genres/tags. Returns whether the manga is new,
//!   updated (metadata_hash changed), or unchanged.
//! - [`sync_manga_chapters`]: per-manga chapter reconciliation. Adds
//!   new ones, refreshes URLs on existing ones, soft-drops vanished.
//! - [`mark_run_started`] / [`mark_run_completed`] /
//!   [`last_run_completed_cleanly`]: per-source recovery flag in
//!   `crawler_state`. A `false` flag on tick start means the previous
//!   run did not exit cleanly and the next walk should ignore the
//!   early-stop condition.
//!
//! Each public function is a transaction boundary so a partial failure
//! mid-call leaves the DB in its pre-call state.

use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::{FromRow, PgPool, Postgres, Transaction};
use uuid::Uuid;

use crate::crawler::source::{SourceChapterRef, SourceManga};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpsertStatus {
    New,
    Updated,
    Unchanged,
}

#[derive(Debug, Clone)]
pub struct UpsertedManga {
    pub manga_id: Uuid,
    pub status: UpsertStatus,
    /// Current value of `mangas.cover_image_path` after the upsert.
    /// `None` means the cover hasn't been downloaded yet — the caller
    /// uses this to backfill covers for mangas that were synced before
    /// cover-download support existed.
    pub cover_image_path: Option<String>,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ChapterDiff {
    pub new: usize,
    pub refreshed: usize,
    pub dropped: usize,
}

pub async fn ensure_source(
    pool: &PgPool,
    id: &str,
    name: &str,
    base_url: &str,
) -> sqlx::Result<()> {
    sqlx::query(
        r#"
        INSERT INTO sources (id, name, base_url, enabled)
        VALUES ($1, $2, $3, true)
        ON CONFLICT (id) DO UPDATE
        SET name = EXCLUDED.name,
            base_url = EXCLUDED.base_url
        "#,
    )
    .bind(id)
    .bind(name)
    .bind(base_url)
    .execute(pool)
    .await?;
    Ok(())
}

pub async fn upsert_manga_from_source(
    pool: &PgPool,
    source_id: &str,
    source_url: &str,
    sm: &SourceManga,
) -> sqlx::Result<UpsertedManga> {
    let mut tx = pool.begin().await?;

    let existing: Option<(Uuid, Option<String>)> = sqlx::query_as(
        r#"
        SELECT manga_id, metadata_hash
          FROM manga_sources
         WHERE source_id = $1 AND source_manga_key = $2
        "#,
    )
    .bind(source_id)
    .bind(&sm.source_manga_key)
    .fetch_optional(&mut *tx)
    .await?;

    let status_db = sm.status.as_deref().unwrap_or("ongoing");

    // Note: `cover_image_path` is intentionally not written here.
    // The repo layer doesn't know about the storage backend, so the
    // caller (crawler binary) downloads the cover via the `Storage`
    // trait and sets the path with `repo::manga::set_cover_image_path`
    // once the bytes have landed.
    let (manga_id, status) = match existing {
        None => {
            let (id,): (Uuid,) = sqlx::query_as(
                r#"
                INSERT INTO mangas (title, description, status, alt_titles)
                VALUES ($1, $2, $3, $4)
                RETURNING id
                "#,
            )
            .bind(&sm.title)
            .bind(sm.summary.as_deref())
            .bind(status_db)
            .bind(&sm.alternative_titles)
            .fetch_one(&mut *tx)
            .await?;
            (id, UpsertStatus::New)
        }
        Some((id, prev_hash)) if prev_hash.as_deref() == Some(&sm.metadata_hash) => {
            (id, UpsertStatus::Unchanged)
        }
        Some((id, _)) => {
            sqlx::query(
                r#"
                UPDATE mangas
                   SET title = $1,
                       description = $2,
                       status = $3,
                       alt_titles = $4,
                       updated_at = NOW()
                 WHERE id = $5
                "#,
            )
            .bind(&sm.title)
            .bind(sm.summary.as_deref())
            .bind(status_db)
            .bind(&sm.alternative_titles)
            .bind(id)
            .execute(&mut *tx)
            .await?;
            (id, UpsertStatus::Updated)
        }
    };

    sqlx::query(
        r#"
        INSERT INTO manga_sources
          (source_id, source_manga_key, manga_id, source_url, metadata_hash, last_seen_at, dropped_at)
        VALUES ($1, $2, $3, $4, $5, NOW(), NULL)
        ON CONFLICT (source_id, source_manga_key) DO UPDATE
        SET source_url = EXCLUDED.source_url,
            metadata_hash = EXCLUDED.metadata_hash,
            last_seen_at = NOW(),
            dropped_at = NULL
        "#,
    )
    .bind(source_id)
    .bind(&sm.source_manga_key)
    .bind(manga_id)
    .bind(source_url)
    .bind(&sm.metadata_hash)
    .execute(&mut *tx)
    .await?;

    sync_authors(&mut tx, manga_id, &sm.authors).await?;
    sync_genres(&mut tx, manga_id, &sm.genres).await?;
    sync_tags(&mut tx, manga_id, &sm.tags).await?;

    let cover_image_path: Option<String> =
        sqlx::query_scalar("SELECT cover_image_path FROM mangas WHERE id = $1")
            .bind(manga_id)
            .fetch_one(&mut *tx)
            .await?;

    tx.commit().await?;
    Ok(UpsertedManga {
        manga_id,
        status,
        cover_image_path,
    })
}

async fn sync_authors(
    tx: &mut Transaction<'_, Postgres>,
    manga_id: Uuid,
    authors: &[String],
) -> sqlx::Result<()> {
    sqlx::query("DELETE FROM manga_authors WHERE manga_id = $1")
        .bind(manga_id)
        .execute(&mut **tx)
        .await?;
    for (i, name) in authors.iter().enumerate() {
        let trimmed = name.trim();
        if trimmed.is_empty() {
            continue;
        }
        // Self-update on conflict so the row id is always returned —
        // can't use DO NOTHING because that suppresses RETURNING.
        let (author_id,): (Uuid,) = sqlx::query_as(
            r#"
            INSERT INTO authors (name) VALUES ($1)
            ON CONFLICT (lower(name)) DO UPDATE SET name = authors.name
            RETURNING id
            "#,
        )
        .bind(trimmed)
        .fetch_one(&mut **tx)
        .await?;
        sqlx::query(
            r#"
            INSERT INTO manga_authors (manga_id, author_id, position)
            VALUES ($1, $2, $3)
            ON CONFLICT DO NOTHING
            "#,
        )
        .bind(manga_id)
        .bind(author_id)
        .bind(i as i32)
        .execute(&mut **tx)
        .await?;
    }
    Ok(())
}

async fn sync_genres(
    tx: &mut Transaction<'_, Postgres>,
    manga_id: Uuid,
    genres: &[String],
) -> sqlx::Result<()> {
    sqlx::query("DELETE FROM manga_genres WHERE manga_id = $1")
        .bind(manga_id)
        .execute(&mut **tx)
        .await?;
    for name in genres {
        let trimmed = name.trim();
        if trimmed.is_empty() {
            continue;
        }
        // Case-insensitive lookup so a source-supplied "action"
        // attaches to the seeded "Action" rather than creating a
        // second row.
        let existing: Option<(Uuid,)> =
            sqlx::query_as("SELECT id FROM genres WHERE lower(name) = lower($1)")
                .bind(trimmed)
                .fetch_optional(&mut **tx)
                .await?;
        let genre_id = match existing {
            Some((id,)) => id,
            None => {
                let (id,): (Uuid,) = sqlx::query_as(
                    r#"
                    INSERT INTO genres (name) VALUES ($1)
                    ON CONFLICT (name) DO UPDATE SET name = genres.name
                    RETURNING id
                    "#,
                )
                .bind(trimmed)
                .fetch_one(&mut **tx)
                .await?;
                tracing::info!(genre = trimmed, "added new genre from source");
                id
            }
        };
        sqlx::query(
            "INSERT INTO manga_genres (manga_id, genre_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
        )
        .bind(manga_id)
        .bind(genre_id)
        .execute(&mut **tx)
        .await?;
    }
    Ok(())
}

async fn sync_tags(
    tx: &mut Transaction<'_, Postgres>,
    manga_id: Uuid,
    tags: &[String],
) -> sqlx::Result<()> {
    // Only clear crawler-owned attachments (added_by IS NULL). User-
    // attached tags are owned by the attaching user and must survive
    // the recurring metadata pass — see manga_tags.added_by in
    // migration 0009.
    //
    // Note on orphans: `manga_tags.added_by` is `ON DELETE SET NULL`,
    // so an attachment whose user was deleted becomes
    // indistinguishable from a crawler-owned row and is cleaned up
    // here. That mirrors how `api::mangas::detach_tag` already treats
    // orphans ("nobody owns it, refuse to let anyone but admin clear
    // them") — the crawler now becomes the eventual reaper. Tracked
    // by `sync_tags_garbage_collects_orphan_user_attachments` in
    // backend/tests/crawler_sync.rs.
    sqlx::query("DELETE FROM manga_tags WHERE manga_id = $1 AND added_by IS NULL")
        .bind(manga_id)
        .execute(&mut **tx)
        .await?;
    for name in tags {
        let trimmed = name.trim();
        if trimmed.is_empty() {
            continue;
        }
        let (tag_id,): (Uuid,) = sqlx::query_as(
            r#"
            INSERT INTO tags (name) VALUES ($1)
            ON CONFLICT (lower(name)) DO UPDATE SET name = tags.name
            RETURNING id
            "#,
        )
        .bind(trimmed)
        .fetch_one(&mut **tx)
        .await?;
        sqlx::query(
            r#"
            INSERT INTO manga_tags (manga_id, tag_id, added_by)
            VALUES ($1, $2, NULL)
            ON CONFLICT DO NOTHING
            "#,
        )
        .bind(manga_id)
        .bind(tag_id)
        .execute(&mut **tx)
        .await?;
    }
    Ok(())
}

pub async fn sync_manga_chapters(
    pool: &PgPool,
    source_id: &str,
    manga_id: Uuid,
    chapters: &[SourceChapterRef],
) -> sqlx::Result<ChapterDiff> {
    let mut tx = pool.begin().await?;
    // Per-manga advisory lock. Two concurrent calls for the same manga
    // would otherwise both read `seen_keys`, both run the drop UPDATE
    // filtered on `NOT (key = ANY $3)`, and the later commit could soft-
    // drop a chapter the earlier commit had just inserted (lost-update
    // shape under MVCC). `pg_advisory_xact_lock` is scoped to this
    // transaction: it auto-releases on COMMIT/ROLLBACK so a Rust-side
    // panic mid-call doesn't strand the lock. The single-arg int8 form
    // keyed by `hashtextextended(manga_id::text, 0)` shares Postgres'
    // global advisory-lock namespace with `CRON_LOCK_KEY`, but collision
    // is 2^-64 per pair (a UUID-derived hash hitting the fixed cron key
    // is effectively impossible).
    sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))")
        .bind(manga_id)
        .execute(&mut *tx)
        .await?;

    let mut diff = ChapterDiff::default();
    let seen_keys: Vec<String> = chapters
        .iter()
        .map(|c| c.source_chapter_key.clone())
        .collect();

    for (idx, c) in chapters.iter().enumerate() {
        // `source_index` captures the chapter's position in the source
        // DOM (0 = first = newest on this site) so the list query can
        // reverse it for the user-facing list — see migration 0021.
        // Every sync overwrites the value on both branches, so a new
        // chapter inserted at the top of the source shifts every other
        // row down by one on the next tick.
        let source_index = idx as i32;
        // Lookup is constrained by manga_id (via the chapters join) so a
        // source whose chapter slugs collide across mangas (e.g.
        // "chapter-1" appearing under two different mangas) attributes
        // each row to the correct manga. Migration 0017 dropped the
        // (source_id, source_chapter_key) PK in favour of
        // (source_id, chapter_id) for exactly this reason.
        let existing: Option<(Uuid,)> = sqlx::query_as(
            "SELECT cs.chapter_id \
               FROM chapter_sources cs \
               JOIN chapters ch ON ch.id = cs.chapter_id \
              WHERE cs.source_id = $1 \
                AND cs.source_chapter_key = $2 \
                AND ch.manga_id = $3",
        )
        .bind(source_id)
        .bind(&c.source_chapter_key)
        .bind(manga_id)
        .fetch_optional(&mut *tx)
        .await?;

        match existing {
            None => {
                // New chapter row. As of 0013 there's no (manga_id,
                // number) UNIQUE, so duplicate-numbered chapters from
                // the source (different uploaders, notices, alt
                // translations) each get their own row — chapter
                // identity is the UUID, not the number.
                let (chapter_id,): (Uuid,) = sqlx::query_as(
                    r#"
                    INSERT INTO chapters (manga_id, number, title, page_count, source_index)
                    VALUES ($1, $2, $3, 0, $4)
                    RETURNING id
                    "#,
                )
                .bind(manga_id)
                .bind(c.number)
                .bind(c.title.as_deref())
                .bind(source_index)
                .fetch_one(&mut *tx)
                .await?;
                sqlx::query(
                    r#"
                    INSERT INTO chapter_sources
                      (source_id, source_chapter_key, chapter_id, source_url, last_seen_at, dropped_at)
                    VALUES ($1, $2, $3, $4, NOW(), NULL)
                    "#,
                )
                .bind(source_id)
                .bind(&c.source_chapter_key)
                .bind(chapter_id)
                .bind(&c.url)
                .execute(&mut *tx)
                .await?;
                diff.new += 1;
            }
            Some((chapter_id,)) => {
                sqlx::query(
                    "UPDATE chapters SET title = $1, source_index = $2 WHERE id = $3",
                )
                    .bind(c.title.as_deref())
                    .bind(source_index)
                    .bind(chapter_id)
                    .execute(&mut *tx)
                    .await?;
                // chapter_id is now the natural per-(source, chapter)
                // identifier — use it directly instead of re-keying on
                // (source_id, source_chapter_key) which may not be unique.
                sqlx::query(
                    r#"
                    UPDATE chapter_sources
                       SET source_url = $1, last_seen_at = NOW(), dropped_at = NULL
                     WHERE source_id = $2 AND chapter_id = $3
                    "#,
                )
                .bind(&c.url)
                .bind(source_id)
                .bind(chapter_id)
                .execute(&mut *tx)
                .await?;
                diff.refreshed += 1;
            }
        }
    }

    // Soft-drop any chapter previously seen from this source for this
    // manga that's not in the current list.
    let result = sqlx::query(
        r#"
        UPDATE chapter_sources cs
           SET dropped_at = NOW()
          FROM chapters ch
         WHERE cs.chapter_id = ch.id
           AND ch.manga_id = $1
           AND cs.source_id = $2
           AND cs.dropped_at IS NULL
           AND NOT (cs.source_chapter_key = ANY($3))
        "#,
    )
    .bind(manga_id)
    .bind(source_id)
    .bind(&seen_keys)
    .execute(&mut *tx)
    .await?;
    diff.dropped = result.rows_affected() as usize;

    tx.commit().await?;
    Ok(diff)
}

/// Count the chapters that the source `(source_id, source_manga_key)`
/// is currently known to attach to — i.e. the number of `chapter_sources`
/// rows for the manga identified by the (source_id, source_manga_key)
/// pair, restricted to live (`dropped_at IS NULL`) rows.
///
/// Used by the metadata pass's partial-render guard: if `fetch_manga`
/// returns an empty `chapters` Vec but the source previously surfaced
/// chapters here, that's most likely a chromium snapshot taken between
/// the `#chapter_table` wrapper render and its rows render — the
/// safest move is to skip `sync_manga_chapters` so the soft-drop
/// branch doesn't flip every existing chapter to `dropped_at`.
///
/// Returns `Ok(0)` when the manga is brand-new (no `manga_sources`
/// row yet), which is the legitimate "this manga has no chapters yet"
/// case and must NOT be flagged.
pub async fn live_chapter_count_for_source_manga(
    pool: &PgPool,
    source_id: &str,
    source_manga_key: &str,
) -> sqlx::Result<i64> {
    let row: Option<(i64,)> = sqlx::query_as(
        "SELECT COUNT(*) \
           FROM chapter_sources cs \
           JOIN chapters c ON c.id = cs.chapter_id \
           JOIN manga_sources ms \
             ON ms.manga_id = c.manga_id \
            AND ms.source_id = cs.source_id \
          WHERE ms.source_id = $1 \
            AND ms.source_manga_key = $2 \
            AND cs.dropped_at IS NULL",
    )
    .bind(source_id)
    .bind(source_manga_key)
    .fetch_optional(pool)
    .await?;
    Ok(row.map(|(n,)| n).unwrap_or(0))
}

/// Mark a metadata pass as in-flight for `source_id`. Stamps
/// `last_run_completed:<source_id>` in `crawler_state` with
/// `{"completed": false, "at": now}`. A crash, panic, or SIGKILL after
/// this point leaves the flag at `false`, which the next tick reads as
/// "previous run did not exit cleanly — walk the full catalog this
/// time" (recovery sweep).
pub async fn mark_run_started(pool: &PgPool, source_id: &str) -> sqlx::Result<()> {
    let key = format!("last_run_completed:{source_id}");
    sqlx::query(
        "INSERT INTO crawler_state (key, value, updated_at) \
         VALUES ($1, $2, now()) \
         ON CONFLICT (key) DO UPDATE \
            SET value = EXCLUDED.value, updated_at = now()",
    )
    .bind(&key)
    .bind(serde_json::json!({
        "completed": false,
        "at": Utc::now().to_rfc3339(),
    }))
    .execute(pool)
    .await?;
    Ok(())
}

/// Mark a metadata pass as completed cleanly for `source_id`. Called
/// from the same place a run decides it reached end-of-walk or hit the
/// intentional stop. The next tick reads `true` and applies the normal
/// stop condition.
pub async fn mark_run_completed(pool: &PgPool, source_id: &str) -> sqlx::Result<()> {
    let key = format!("last_run_completed:{source_id}");
    sqlx::query(
        "INSERT INTO crawler_state (key, value, updated_at) \
         VALUES ($1, $2, now()) \
         ON CONFLICT (key) DO UPDATE \
            SET value = EXCLUDED.value, updated_at = now()",
    )
    .bind(&key)
    .bind(serde_json::json!({
        "completed": true,
        "at": Utc::now().to_rfc3339(),
    }))
    .execute(pool)
    .await?;
    Ok(())
}

/// List mangas whose `cover_image_path IS NULL` but a live
/// `manga_sources` row still attaches them to a source. The bounded
/// result feeds the cover-backfill pass in [`crate::crawler::pipeline`]:
/// each entry is one (manga, freshest source row) pair where a cover
/// re-download is in order.
///
/// Per-manga deduplication uses `DISTINCT ON (m.id)` keyed on the row
/// with the newest `last_seen_at`, so a manga that's surfaced by
/// multiple sources only produces one row (the freshest). Sort is
/// stable for tests.
pub async fn list_missing_covers(
    pool: &PgPool,
    max: i64,
) -> sqlx::Result<Vec<MissingCoverEntry>> {
    let rows: Vec<(Uuid, String, String)> = sqlx::query_as(
        r#"
        SELECT DISTINCT ON (m.id) m.id, ms.source_manga_key, ms.source_url
          FROM mangas m
          JOIN manga_sources ms ON ms.manga_id = m.id
         WHERE m.cover_image_path IS NULL
           AND ms.dropped_at IS NULL
         ORDER BY m.id, ms.last_seen_at DESC
         LIMIT $1
        "#,
    )
    .bind(max)
    .fetch_all(pool)
    .await?;
    Ok(rows
        .into_iter()
        .map(|(manga_id, source_manga_key, source_url)| MissingCoverEntry {
            manga_id,
            source_manga_key,
            source_url,
        })
        .collect())
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingCoverEntry {
    pub manga_id: Uuid,
    pub source_manga_key: String,
    pub source_url: String,
}

/// Read the recovery flag for `source_id`. A missing row OR an
/// unparseable value reads as `true` ("clean") — the former covers the
/// first-ever run on a virgin DB (no recovery needed), the latter
/// covers forward-compat against future schema changes; both fail-safe
/// toward not making an operator pay for an unnecessary full sweep.
pub async fn last_run_completed_cleanly(
    pool: &PgPool,
    source_id: &str,
) -> sqlx::Result<bool> {
    let key = format!("last_run_completed:{source_id}");
    let row: Option<serde_json::Value> =
        sqlx::query_scalar("SELECT value FROM crawler_state WHERE key = $1")
            .bind(&key)
            .fetch_optional(pool)
            .await?;
    Ok(row
        .and_then(|v| v.get("completed").and_then(|b| b.as_bool()))
        .unwrap_or(true))
}

/// Timestamp of the most recent metadata-pass cron tick, or `None` if the
/// daemon has never ticked (fresh DB / cron disabled). Stored by the daemon
/// under `crawler_state['last_metadata_tick_at']` as `{"at": <rfc3339>}`.
/// Used by the health check that flags a stale/stuck cron.
pub async fn last_metadata_tick_at(
    pool: &PgPool,
) -> sqlx::Result<Option<DateTime<Utc>>> {
    let row: Option<serde_json::Value> =
        sqlx::query_scalar("SELECT value FROM crawler_state WHERE key = 'last_metadata_tick_at'")
            .fetch_optional(pool)
            .await?;
    Ok(row.and_then(|v| {
        v.get("at")
            .and_then(|s| s.as_str())
            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
            .map(|dt| dt.with_timezone(&Utc))
    }))
}

// ---------------------------------------------------------------------------
// Reconcile: find source-list mangas missing from the DB.
// ---------------------------------------------------------------------------

/// All `source_manga_key`s we have a `manga_sources` row for under
/// `source_id`. Intentionally **not** filtered by `dropped_at`: a
/// soft-dropped row still counts as "present" so reconcile only enqueues
/// mangas it has truly never seen (strict `NOT EXISTS`).
pub async fn existing_source_keys(
    pool: &PgPool,
    source_id: &str,
) -> sqlx::Result<std::collections::HashSet<String>> {
    let rows: Vec<String> =
        sqlx::query_scalar("SELECT source_manga_key FROM manga_sources WHERE source_id = $1")
            .bind(source_id)
            .fetch_all(pool)
            .await?;
    Ok(rows.into_iter().collect())
}

/// `source_manga_key`s that already have a `SyncManga` job in a state that
/// should block re-enqueue: `pending`/`running` (in flight) **and** `dead`
/// (leave-dead — a gone manga is not retried by a later reconcile). `done`
/// and `failed` (mid-backoff) do not block.
pub async fn sync_manga_keys_with_blocking_job(
    pool: &PgPool,
    source_id: &str,
) -> sqlx::Result<std::collections::HashSet<String>> {
    let rows: Vec<String> = sqlx::query_scalar(
        "SELECT DISTINCT payload->>'source_manga_key' \
           FROM crawler_jobs \
          WHERE payload->>'kind' = 'sync_manga' \
            AND payload->>'source_id' = $1 \
            AND state IN ('pending', 'running', 'dead') \
            AND payload->>'source_manga_key' IS NOT NULL",
    )
    .bind(source_id)
    .fetch_all(pool)
    .await?;
    Ok(rows.into_iter().collect())
}

// ---------------------------------------------------------------------------
// Dead-letter jobs: admin observability + requeue.
// ---------------------------------------------------------------------------

/// A `dead` crawler job joined to its chapter/manga context for the admin
/// dead-letter view. Chapter columns are `Option` because the join is
/// best-effort (the chapter may have been removed since the job died, or
/// the job may be a non-chapter kind).
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct DeadJob {
    pub id: Uuid,
    pub kind: String,
    pub chapter_id: Option<Uuid>,
    pub manga_id: Option<Uuid>,
    pub manga_title: Option<String>,
    pub chapter_number: Option<i32>,
    /// Title carried in the job payload. For `sync_manga` jobs whose manga
    /// was never upserted there is no `manga_id`/`manga_title`, so the UI
    /// falls back to this.
    pub payload_title: Option<String>,
    /// Source detail URL carried in the payload (currently `sync_manga`).
    pub source_url: Option<String>,
    /// Source-native key carried in the payload (currently `sync_manga`).
    pub source_key: Option<String>,
    pub attempts: i32,
    pub max_attempts: i32,
    pub last_error: Option<String>,
    pub updated_at: DateTime<Utc>,
}

/// Paginated list of `dead` jobs, newest-failed first, joined to chapter +
/// manga context. `search` filters on manga title (case-insensitive
/// substring). Returns the page slice plus the unfiltered-by-page total.
pub async fn list_dead_jobs(
    pool: &PgPool,
    search: Option<&str>,
    limit: i64,
    offset: i64,
) -> sqlx::Result<(Vec<DeadJob>, i64)> {
    let search_pat = search
        .map(|s| format!("%{}%", s.trim()))
        .filter(|p| p.len() > 2);

    let items: Vec<DeadJob> = sqlx::query_as(
        r#"
        SELECT
            cj.id,
            cj.payload->>'kind'                       AS kind,
            (cj.payload->>'chapter_id')::uuid         AS chapter_id,
            c.manga_id                                AS manga_id,
            m.title                                   AS manga_title,
            c.number                                  AS chapter_number,
            cj.payload->>'title'                      AS payload_title,
            cj.payload->>'url'                        AS source_url,
            cj.payload->>'source_manga_key'           AS source_key,
            cj.attempts,
            cj.max_attempts,
            cj.last_error,
            cj.updated_at
          FROM crawler_jobs cj
          LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
          LEFT JOIN mangas m   ON m.id = c.manga_id
         WHERE cj.state = 'dead'
           AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1)
         ORDER BY cj.updated_at DESC
         LIMIT $2 OFFSET $3
        "#,
    )
    .bind(&search_pat)
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    let total: i64 = sqlx::query_scalar(
        r#"
        SELECT COUNT(*)
          FROM crawler_jobs cj
          LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
          LEFT JOIN mangas m   ON m.id = c.manga_id
         WHERE cj.state = 'dead'
           AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1)
        "#,
    )
    .bind(&search_pat)
    .fetch_one(pool)
    .await?;

    Ok((items, total))
}

/// An in-flight chapter-content job (`pending` or `running`) joined to its
/// chapter + manga, for the "queued chapters" admin view.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ActiveJob {
    pub id: Uuid,
    pub chapter_id: Option<Uuid>,
    pub manga_id: Option<Uuid>,
    pub manga_title: Option<String>,
    pub chapter_number: Option<i32>,
    /// `"pending"` or `"running"`.
    pub state: String,
    pub attempts: i32,
    pub max_attempts: i32,
    pub updated_at: DateTime<Utc>,
}

/// Paginated list of `pending`/`running` chapter-content jobs (which
/// chapters of which mangas are queued or being crawled). Running first,
/// then by scheduled order. `search` filters on manga title.
pub async fn list_active_jobs(
    pool: &PgPool,
    search: Option<&str>,
    limit: i64,
    offset: i64,
) -> sqlx::Result<(Vec<ActiveJob>, i64)> {
    let search_pat = search
        .map(|s| format!("%{}%", s.trim()))
        .filter(|p| p.len() > 2);

    let items: Vec<ActiveJob> = sqlx::query_as(
        r#"
        SELECT
            cj.id,
            (cj.payload->>'chapter_id')::uuid         AS chapter_id,
            c.manga_id                                AS manga_id,
            m.title                                   AS manga_title,
            c.number                                  AS chapter_number,
            cj.state,
            cj.attempts,
            cj.max_attempts,
            cj.updated_at
          FROM crawler_jobs cj
          LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
          LEFT JOIN mangas m   ON m.id = c.manga_id
         WHERE cj.state IN ('pending','running')
           AND cj.payload->>'kind' = 'sync_chapter_content'
           AND ($1::text IS NULL OR m.title ILIKE $1)
         ORDER BY (cj.state = 'running') DESC, cj.scheduled_at, cj.created_at
         LIMIT $2 OFFSET $3
        "#,
    )
    .bind(&search_pat)
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    let total: i64 = sqlx::query_scalar(
        r#"
        SELECT COUNT(*)
          FROM crawler_jobs cj
          LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
          LEFT JOIN mangas m   ON m.id = c.manga_id
         WHERE cj.state IN ('pending','running')
           AND cj.payload->>'kind' = 'sync_chapter_content'
           AND ($1::text IS NULL OR m.title ILIKE $1)
        "#,
    )
    .bind(&search_pat)
    .fetch_one(pool)
    .await?;

    Ok((items, total))
}

// ---------------------------------------------------------------------------
// Job history: unified, searchable, filterable view over the queue table.
// ---------------------------------------------------------------------------

/// A `crawler_jobs` row resolved to human context for the admin history
/// table. Works across all job kinds: the target columns are best-effort
/// `Option`s resolved through whichever payload reference the kind carries
/// (`chapter_id`, `manga_id`, or `page_id` via the page breadcrumb), so a
/// `sync_manga` job (which has neither yet) simply leaves them `None` and
/// falls back to `source_key`.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct JobHistoryRow {
    pub id: Uuid,
    /// `pending` | `running` | `done` | `dead` (the live queue states).
    pub state: String,
    /// `sync_manga` | `sync_chapter_list` | `sync_chapter_content` | `analyze_page`.
    pub kind: Option<String>,
    pub manga_id: Option<Uuid>,
    pub manga_title: Option<String>,
    pub chapter_id: Option<Uuid>,
    pub chapter_number: Option<i32>,
    /// Set only for `analyze_page` (resolved through the page breadcrumb).
    pub page_number: Option<i32>,
    /// Source-side key, the only target a `sync_manga` job carries.
    pub source_key: Option<String>,
    /// Title carried in the payload — the display fallback for `sync_manga`
    /// jobs whose manga was never upserted (no `manga_title`).
    pub payload_title: Option<String>,
    /// Source detail URL carried in the payload (currently `sync_manga`).
    pub source_url: Option<String>,
    pub attempts: i32,
    pub max_attempts: i32,
    pub last_error: Option<String>,
    pub updated_at: DateTime<Utc>,
    /// How long the job's work took, when a timing was recorded: the latest
    /// `chapter` crawl-metric for a chapter job, else the page's analysis
    /// duration for an `analyze_page` job. `None` for kinds we don't time or
    /// jobs not yet completed.
    pub duration_ms: Option<i64>,
}

/// Filters for [`list_job_history`]. All optional; `None` widens the scope.
#[derive(Debug, Default, Clone)]
pub struct JobHistoryFilter<'a> {
    /// Exact queue state (`done`, `dead`, `running`, `pending`).
    pub state: Option<&'a str>,
    /// Exact payload `kind`.
    pub kind: Option<&'a str>,
    /// Case-insensitive manga-title substring.
    pub search: Option<&'a str>,
}

/// Paginated, newest-first view of the job queue across every state and
/// kind — the searchable/filterable history surface. Joins each job to its
/// manga/chapter/page context (best-effort) so the table can label rows.
/// Returns the page slice plus the filtered total for pagination.
///
/// History depth is bounded by the terminal-job reaper (`reap_terminal`):
/// `done` and `dead` jobs older than the retention window are gone.
pub async fn list_job_history(
    pool: &PgPool,
    filter: JobHistoryFilter<'_>,
    limit: i64,
    offset: i64,
) -> sqlx::Result<(Vec<JobHistoryRow>, i64)> {
    let search_pat = filter
        .search
        .map(|s| format!("%{}%", s.trim()))
        .filter(|p| p.len() > 2);

    // The same FROM/JOIN/WHERE drives both the page slice and the count, so
    // they stay in lockstep. `pg` resolves analyze_page → page → chapter;
    // `COALESCE` lets one set of joins serve every kind.
    let items: Vec<JobHistoryRow> = sqlx::query_as(
        r#"
        SELECT
            cj.id,
            cj.state,
            cj.payload->>'kind'                                       AS kind,
            COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)     AS manga_id,
            m.title                                                   AS manga_title,
            COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id) AS chapter_id,
            c.number                                                  AS chapter_number,
            pg.page_number                                            AS page_number,
            cj.payload->>'source_manga_key'                           AS source_key,
            cj.payload->>'title'                                      AS payload_title,
            cj.payload->>'url'                                        AS source_url,
            cj.attempts,
            cj.max_attempts,
            cj.last_error,
            cj.updated_at,
            COALESCE(cm.duration_ms, pa.duration_ms)                  AS duration_ms
          FROM crawler_jobs cj
          LEFT JOIN pages pg    ON pg.id = (cj.payload->>'page_id')::uuid
          LEFT JOIN chapters c  ON c.id = COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id)
          LEFT JOIN mangas m    ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
          LEFT JOIN page_analysis pa ON pa.page_id = (cj.payload->>'page_id')::uuid
          LEFT JOIN LATERAL (
              SELECT duration_ms FROM crawl_metrics
               WHERE op = 'chapter'
                 AND chapter_id = (cj.payload->>'chapter_id')::uuid
               ORDER BY finished_at DESC LIMIT 1
          ) cm ON true
         WHERE ($1::text IS NULL OR cj.state = $1)
           AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
           AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3)
         ORDER BY cj.updated_at DESC
         LIMIT $4 OFFSET $5
        "#,
    )
    .bind(filter.state)
    .bind(filter.kind)
    .bind(&search_pat)
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    let total: i64 = sqlx::query_scalar(
        r#"
        SELECT COUNT(*)
          FROM crawler_jobs cj
          LEFT JOIN pages pg    ON pg.id = (cj.payload->>'page_id')::uuid
          LEFT JOIN chapters c  ON c.id = COALESCE((cj.payload->>'chapter_id')::uuid, pg.chapter_id)
          LEFT JOIN mangas m    ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
         WHERE ($1::text IS NULL OR cj.state = $1)
           AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
           AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3)
        "#,
    )
    .bind(filter.state)
    .bind(filter.kind)
    .bind(&search_pat)
    .fetch_one(pool)
    .await?;

    Ok((items, total))
}

/// A manga whose cover is still missing (queued for cover fetch).
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct MissingCoverRow {
    pub manga_id: Uuid,
    pub manga_title: String,
}

/// Count mangas with no cover yet but a live source row — the cover
/// backlog the metadata pass + backfill drain.
pub async fn count_missing_covers(pool: &PgPool) -> sqlx::Result<i64> {
    sqlx::query_scalar(
        r#"
        SELECT COUNT(*) FROM mangas m
         WHERE m.cover_image_path IS NULL
           AND EXISTS (
               SELECT 1 FROM manga_sources ms
                WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
           )
        "#,
    )
    .fetch_one(pool)
    .await
}

/// Paginated list of mangas queued for a cover fetch (no cover yet + a live
/// source), with titles. `search` filters on title. Freshest source first.
pub async fn list_missing_cover_mangas(
    pool: &PgPool,
    search: Option<&str>,
    limit: i64,
    offset: i64,
) -> sqlx::Result<(Vec<MissingCoverRow>, i64)> {
    let search_pat = search
        .map(|s| format!("%{}%", s.trim()))
        .filter(|p| p.len() > 2);

    let items: Vec<MissingCoverRow> = sqlx::query_as(
        r#"
        SELECT m.id AS manga_id, m.title AS manga_title
          FROM mangas m
         WHERE m.cover_image_path IS NULL
           AND EXISTS (
               SELECT 1 FROM manga_sources ms
                WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
           )
           AND ($1::text IS NULL OR m.title ILIKE $1)
         ORDER BY m.updated_at DESC
         LIMIT $2 OFFSET $3
        "#,
    )
    .bind(&search_pat)
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    let total: i64 = sqlx::query_scalar(
        r#"
        SELECT COUNT(*) FROM mangas m
         WHERE m.cover_image_path IS NULL
           AND EXISTS (
               SELECT 1 FROM manga_sources ms
                WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
           )
           AND ($1::text IS NULL OR m.title ILIKE $1)
        "#,
    )
    .bind(&search_pat)
    .fetch_one(pool)
    .await?;

    Ok((items, total))
}

/// Scope of a dead-job requeue.
#[derive(Debug, Clone)]
pub enum RequeueScope {
    /// Every dead job.
    All,
    /// Dead jobs whose chapter belongs to this manga.
    Manga(Uuid),
    /// Dead jobs for a single chapter.
    Chapter(Uuid),
    /// A single dead job by its id.
    Job(Uuid),
}

/// Requeue dead jobs back to `pending` with a fresh attempt budget. This is
/// an explicit operator override, so it bypasses the dead-letter quarantine
/// the enqueue helpers honour (we act directly on the row). Returns the
/// number of rows requeued.
///
/// Two invariants protect the partial unique dedup index
/// `crawler_jobs_chapter_content_dedup_idx` (one `pending|running`
/// sync_chapter_content job per chapter):
///   1. A chapter that already has a live (`pending|running`) job is
///      skipped entirely (`NO_LIVE_DUP`).
///   2. When a chapter has *multiple* dead jobs, only the newest is
///      revived (`DISTINCT ON` the chapter key) — without this, flipping
///      two dead rows for the same chapter to `pending` in one statement
///      would violate the index and abort the whole requeue. Non-chapter
///      jobs fall back to their row id so each stays distinct.
pub async fn requeue_dead_jobs(pool: &PgPool, scope: RequeueScope) -> sqlx::Result<u64> {
    // One full-shape SQL string per scope. Previously the scope
    // predicate was spliced via `format!()` from a `&'static str`
    // match — structurally safe today but fragile against a later
    // refactor accidentally interpolating a non-literal. Four fixed
    // queries cost a few duplicated lines but are immune to that
    // class of bug, and each can be reviewed independently. The CTE
    // body (DISTINCT ON dedup + NOT EXISTS guard) is identical
    // everywhere so the duplication is mechanical, not semantic.
    let q = match scope {
        RequeueScope::All => sqlx::query(REQUEUE_DEAD_SQL_ALL),
        RequeueScope::Manga(id) => sqlx::query(REQUEUE_DEAD_SQL_MANGA).bind(id),
        RequeueScope::Chapter(id) => sqlx::query(REQUEUE_DEAD_SQL_CHAPTER).bind(id),
        RequeueScope::Job(id) => sqlx::query(REQUEUE_DEAD_SQL_JOB).bind(id),
    };
    Ok(q.execute(pool).await?.rows_affected())
}

/// Common shell of the requeue CTE. Each scope variant inlines the
/// shell and substitutes its own WHERE clause; the duplication is the
/// price of avoiding runtime SQL string assembly. The dedup guarantees
/// documented on [`requeue_dead_jobs`] live in the `DISTINCT ON` and
/// the `NOT EXISTS` block — keep those identical across the four
/// constants below when editing.
const REQUEUE_DEAD_SQL_ALL: &str = r#"
    WITH pick AS (
        SELECT DISTINCT ON (COALESCE(cj.payload->>'chapter_id', cj.id::text)) cj.id
          FROM crawler_jobs cj
         WHERE cj.state = 'dead'
           AND NOT EXISTS (
               SELECT 1 FROM crawler_jobs live
                WHERE live.payload->>'kind' = 'sync_chapter_content'
                  AND live.payload->>'chapter_id' = cj.payload->>'chapter_id'
                  AND live.state IN ('pending','running')
           )
         ORDER BY COALESCE(cj.payload->>'chapter_id', cj.id::text), cj.updated_at DESC
    )
    UPDATE crawler_jobs
       SET state = 'pending', attempts = 0, leased_until = NULL,
           last_error = NULL, scheduled_at = now(), updated_at = now()
      FROM pick
     WHERE crawler_jobs.id = pick.id
"#;

const REQUEUE_DEAD_SQL_MANGA: &str = r#"
    WITH pick AS (
        SELECT DISTINCT ON (COALESCE(cj.payload->>'chapter_id', cj.id::text)) cj.id
          FROM crawler_jobs cj
         WHERE cj.state = 'dead'
           AND (cj.payload->>'chapter_id')::uuid IN
               (SELECT id FROM chapters WHERE manga_id = $1)
           AND NOT EXISTS (
               SELECT 1 FROM crawler_jobs live
                WHERE live.payload->>'kind' = 'sync_chapter_content'
                  AND live.payload->>'chapter_id' = cj.payload->>'chapter_id'
                  AND live.state IN ('pending','running')
           )
         ORDER BY COALESCE(cj.payload->>'chapter_id', cj.id::text), cj.updated_at DESC
    )
    UPDATE crawler_jobs
       SET state = 'pending', attempts = 0, leased_until = NULL,
           last_error = NULL, scheduled_at = now(), updated_at = now()
      FROM pick
     WHERE crawler_jobs.id = pick.id
"#;

const REQUEUE_DEAD_SQL_CHAPTER: &str = r#"
    WITH pick AS (
        SELECT DISTINCT ON (COALESCE(cj.payload->>'chapter_id', cj.id::text)) cj.id
          FROM crawler_jobs cj
         WHERE cj.state = 'dead'
           AND (cj.payload->>'chapter_id')::uuid = $1
           AND NOT EXISTS (
               SELECT 1 FROM crawler_jobs live
                WHERE live.payload->>'kind' = 'sync_chapter_content'
                  AND live.payload->>'chapter_id' = cj.payload->>'chapter_id'
                  AND live.state IN ('pending','running')
           )
         ORDER BY COALESCE(cj.payload->>'chapter_id', cj.id::text), cj.updated_at DESC
    )
    UPDATE crawler_jobs
       SET state = 'pending', attempts = 0, leased_until = NULL,
           last_error = NULL, scheduled_at = now(), updated_at = now()
      FROM pick
     WHERE crawler_jobs.id = pick.id
"#;

const REQUEUE_DEAD_SQL_JOB: &str = r#"
    WITH pick AS (
        SELECT DISTINCT ON (COALESCE(cj.payload->>'chapter_id', cj.id::text)) cj.id
          FROM crawler_jobs cj
         WHERE cj.state = 'dead'
           AND cj.id = $1
           AND NOT EXISTS (
               SELECT 1 FROM crawler_jobs live
                WHERE live.payload->>'kind' = 'sync_chapter_content'
                  AND live.payload->>'chapter_id' = cj.payload->>'chapter_id'
                  AND live.state IN ('pending','running')
           )
         ORDER BY COALESCE(cj.payload->>'chapter_id', cj.id::text), cj.updated_at DESC
    )
    UPDATE crawler_jobs
       SET state = 'pending', attempts = 0, leased_until = NULL,
           last_error = NULL, scheduled_at = now(), updated_at = now()
      FROM pick
     WHERE crawler_jobs.id = pick.id
"#;

/// `crawler_state` key under which the runtime session value (an admin
/// pushed PHPSESSID) is persisted. Survives a backend restart so a
/// mid-day refresh isn't lost.
const STATE_KEY_RUNTIME_SESSION: &str = "runtime_session";

/// Read the persisted runtime PHPSESSID (if any). The payload shape is
/// `{ "phpsessid": "<value>" }`; anything else returns `None`.
pub async fn runtime_session_load(pool: &PgPool) -> sqlx::Result<Option<String>> {
    let row: Option<serde_json::Value> =
        sqlx::query_scalar("SELECT value FROM crawler_state WHERE key = $1")
            .bind(STATE_KEY_RUNTIME_SESSION)
            .fetch_optional(pool)
            .await?;
    Ok(row.and_then(|v| {
        v.get("phpsessid")
            .and_then(|s| s.as_str())
            .map(|s| s.to_string())
    }))
}

/// Persist a fresh runtime PHPSESSID, replacing any previous value.
pub async fn runtime_session_persist(pool: &PgPool, sid: &str) -> sqlx::Result<()> {
    sqlx::query(
        "INSERT INTO crawler_state (key, value, updated_at) \
         VALUES ($1, $2, now()) \
         ON CONFLICT (key) DO UPDATE \
            SET value = EXCLUDED.value, updated_at = now()",
    )
    .bind(STATE_KEY_RUNTIME_SESSION)
    .bind(serde_json::json!({ "phpsessid": sid }))
    .execute(pool)
    .await?;
    Ok(())
}

/// Count crawler jobs grouped by state — drives the dashboard queue
/// gauges. Returns `(pending, running, dead)`.
pub async fn job_state_counts(pool: &PgPool) -> sqlx::Result<(i64, i64, i64)> {
    let rows: Vec<(String, i64)> =
        sqlx::query_as("SELECT state, COUNT(*) FROM crawler_jobs GROUP BY state")
            .fetch_all(pool)
            .await?;
    let mut pending = 0;
    let mut running = 0;
    let mut dead = 0;
    for (state, n) in rows {
        match state.as_str() {
            "pending" => pending = n,
            "running" => running = n,
            "dead" => dead = n,
            _ => {}
        }
    }
    Ok((pending, running, dead))
}

