//! Integration tests for the AI-analysis enqueue wiring: chapter upload
//! enqueues `analyze_page` jobs, and the admin backfill / force-reanalyze
//! endpoints. All gated on `analysis_enabled` (the `harness_with_analysis`
//! variant turns it on; the default harness leaves it off → 503).

mod common;

use axum::http::StatusCode;
use axum::Router;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;

use mangalord::repo;

async fn seed_admin(pool: &PgPool, app: &Router) -> String {
    let (username, cookie) = common::register_user(app).await;
    let u = repo::user::find_by_username(pool, &username)
        .await
        .unwrap()
        .unwrap();
    repo::user::set_is_admin_unchecked(pool, u.id, true)
        .await
        .unwrap();
    cookie
}

/// Seed a manga → chapter with `n` pages, returning (manga_id, chapter_id,
/// page_ids).
async fn seed_manga_chapter_pages(
    pool: &PgPool,
    n: i32,
) -> (uuid::Uuid, uuid::Uuid, Vec<uuid::Uuid>) {
    let manga_id: uuid::Uuid =
        sqlx::query_scalar("INSERT INTO mangas (title) VALUES ('M') RETURNING id")
            .fetch_one(pool)
            .await
            .unwrap();
    let chapter_id: uuid::Uuid = sqlx::query_scalar(
        "INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
    )
    .bind(manga_id)
    .fetch_one(pool)
    .await
    .unwrap();
    let mut ids = Vec::new();
    for i in 1..=n {
        let id: uuid::Uuid = sqlx::query_scalar(
            "INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
             VALUES ($1, $2, $3, 'image/png') RETURNING id",
        )
        .bind(chapter_id)
        .bind(i)
        .bind(format!("k/{manga_id}/{i}.png"))
        .fetch_one(pool)
        .await
        .unwrap();
        ids.push(id);
    }
    (manga_id, chapter_id, ids)
}

/// Convenience wrapper for tests that only need the page ids.
async fn seed_pages(pool: &PgPool, n: i32) -> Vec<uuid::Uuid> {
    seed_manga_chapter_pages(pool, n).await.2
}

async fn analyze_job_count(pool: &PgPool) -> i64 {
    sqlx::query_scalar(
        "SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
    )
    .fetch_one(pool)
    .await
    .unwrap()
}

#[sqlx::test(migrations = "./migrations")]
async fn force_analyze_enqueue_rolls_back_with_its_transaction(pool: PgPool) {
    // The admin force-reanalyze handler runs enqueue_for_page_conn + the audit
    // insert in one transaction. Prove the enqueue genuinely participates in
    // that tx: when the tx is abandoned (the path taken if the audit insert
    // fails and the `?` propagates), no job survives — so the audit trail can
    // never miss a force-reanalyze that actually landed.
    let page_id = seed_pages(&pool, 1).await[0];

    let mut tx = pool.begin().await.unwrap();
    let outcome =
        mangalord::repo::page_analysis::enqueue_for_page_conn(&mut tx, page_id, true)
            .await
            .unwrap();
    assert!(matches!(
        outcome,
        mangalord::repo::page_analysis::EnqueueForPageOutcome::Inserted
    ));
    // Visible inside the open transaction…
    let in_tx: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
    )
    .fetch_one(&mut *tx)
    .await
    .unwrap();
    assert_eq!(in_tx, 1);

    // …but gone once the tx rolls back instead of committing.
    tx.rollback().await.unwrap();
    assert_eq!(analyze_job_count(&pool).await, 0);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_upload_enqueues_one_analysis_job_per_page(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let (_user, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Uploaded").await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_multipart_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/chapters"),
            common::MultipartBuilder::new()
                .add_json("metadata", json!({ "number": 1, "title": "Ch 1" }))
                .add_file("page", "p1.png", "image/png", &common::fake_png_bytes())
                .add_file("page", "p2.png", "image/png", &common::fake_png_bytes()),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    assert_eq!(analyze_job_count(&pool).await, 2);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_upload_does_not_enqueue_when_analysis_disabled(pool: PgPool) {
    // Default harness has analysis_enabled = false.
    let h = common::harness(pool.clone());
    let (_user, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Uploaded").await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_multipart_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/chapters"),
            common::MultipartBuilder::new()
                .add_json("metadata", json!({ "number": 1 }))
                .add_file("page", "p1.png", "image/png", &common::fake_png_bytes()),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    assert_eq!(analyze_job_count(&pool).await, 0);
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_is_forbidden_for_non_admin(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let (_user, cookie) = common::register_user(&h.app).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_returns_503_when_analysis_disabled(pool: PgPool) {
    let h = common::harness(pool.clone()); // analysis disabled
    let cookie = seed_admin(&pool, &h.app).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_backfills_existing_pages(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    seed_pages(&pool, 3).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({ "only_unanalyzed": true }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["enqueued"], 3);
    assert_eq!(analyze_job_count(&pool).await, 3);

    // Idempotent: a second call enqueues nothing (pending jobs already exist).
    let resp2 = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({ "only_unanalyzed": true }),
            &cookie,
        ))
        .await
        .unwrap();
    let body2 = common::body_json(resp2).await;
    assert_eq!(body2["enqueued"], 0);
    assert_eq!(analyze_job_count(&pool).await, 3);
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_writes_audit_row_atomically_with_jobs(pool: PgPool) {
    // The enqueue and its admin_audit row are committed in one transaction,
    // so a successful re-enqueue always leaves both the jobs AND exactly one
    // matching audit row — the audit trail can't miss an enqueue that landed.
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    seed_pages(&pool, 2).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({ "only_unanalyzed": true }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(analyze_job_count(&pool).await, 2);

    let audit: serde_json::Value = sqlx::query_scalar(
        "SELECT payload FROM admin_audit WHERE action = 'analysis_reenqueue'",
    )
    .fetch_one(&pool)
    .await
    .expect("exactly one analysis_reenqueue audit row committed with the jobs");
    assert_eq!(audit["enqueued"], 2);
    assert_eq!(audit["only_unanalyzed"], true);
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_scoped_to_manga(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let (manga_a, _ch_a, _pa) = seed_manga_chapter_pages(&pool, 2).await;
    seed_manga_chapter_pages(&pool, 3).await; // a different manga

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({ "manga_id": manga_a }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["enqueued"], 2, "only manga_a's pages enqueued");
    assert_eq!(analyze_job_count(&pool).await, 2);
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_scoped_to_chapter(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let (_m, chapter_id, _ids) = seed_manga_chapter_pages(&pool, 4).await;
    seed_manga_chapter_pages(&pool, 5).await; // unrelated chapter

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({ "chapter_id": chapter_id }),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["enqueued"], 4);
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_including_analyzed_sets_force_and_covers_done_pages(pool: PgPool) {
    use mangalord::domain::page_analysis::{SafetyFlag, VisionAnalysis};

    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let ids = seed_pages(&pool, 2).await;
    // First page already analyzed.
    repo::page_analysis::persist_analysis(
        &pool,
        ids[0],
        &VisionAnalysis {
            ocr_results: vec![],
            tagging_results: vec![],
            scene_description: String::new(),
            safety_flag: SafetyFlag::default(),
        },
        "m",
    )
    .await
    .unwrap();

    // only_unanalyzed=false → include the done page, with force=true.
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({ "only_unanalyzed": false }),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["enqueued"], 2, "both pages enqueued (incl. the done one)");

    // The job for the already-done page must carry force=true so the worker
    // re-analyzes instead of skipping it.
    let force: bool = sqlx::query_scalar(
        "SELECT (payload->>'force')::boolean FROM crawler_jobs \
          WHERE payload->>'page_id' = $1",
    )
    .bind(ids[0].to_string())
    .fetch_one(&pool)
    .await
    .unwrap();
    assert!(force, "include-analyzed jobs must force re-analysis");
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_manga_and_chapter_are_mutually_exclusive(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({ "manga_id": uuid::Uuid::new_v4(), "chapter_id": uuid::Uuid::new_v4() }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_unknown_scope_target_is_404(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;

    for body in [
        json!({ "manga_id": uuid::Uuid::new_v4() }),
        json!({ "chapter_id": uuid::Uuid::new_v4() }),
    ] {
        let resp = h
            .app
            .clone()
            .oneshot(common::post_json_with_cookie(
                "/api/v1/admin/analysis/reenqueue",
                body,
                &cookie,
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    }
}

#[sqlx::test(migrations = "./migrations")]
async fn reenqueue_only_unanalyzed_skips_done_pages(pool: PgPool) {
    use mangalord::domain::page_analysis::{SafetyFlag, VisionAnalysis};

    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let ids = seed_pages(&pool, 2).await;

    // Mark the first page as already analyzed.
    repo::page_analysis::persist_analysis(
        &pool,
        ids[0],
        &VisionAnalysis {
            ocr_results: vec![],
            tagging_results: vec![],
            scene_description: String::new(),
            safety_flag: SafetyFlag::default(),
        },
        "m",
    )
    .await
    .unwrap();

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/analysis/reenqueue",
            json!({ "only_unanalyzed": true }),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["enqueued"], 1, "only the un-analyzed page is enqueued");
}

#[sqlx::test(migrations = "./migrations")]
async fn force_analyze_page_enqueues_with_force_flag(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let ids = seed_pages(&pool, 1).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/pages/{}/analyze", ids[0]),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let payload: serde_json::Value = sqlx::query_scalar(
        "SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
    )
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(payload["force"].as_bool(), Some(true));
}

#[sqlx::test(migrations = "./migrations")]
async fn force_analyze_upgrades_pending_non_force_to_force(pool: PgPool) {
    // The cover-up case the partial unique index used to silently
    // create: when a `force=false` job is already pending and admin
    // clicks force, the INSERT used to dedup-skip silently. The worker
    // then picked up the non-force row, hit skip-if-done on a `done`
    // page, and admin saw "queued" with no re-analysis. 0.87.11
    // upgrades the pending row in-place instead.
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let ids = seed_pages(&pool, 1).await;
    let page_id = ids[0];

    // 1) Pre-existing non-force pending row (simulates the crawler's
    //    post-upload enqueue path).
    mangalord::repo::page_analysis::enqueue_for_page(&pool, page_id, false)
        .await
        .unwrap();
    let payload_before: serde_json::Value = sqlx::query_scalar(
        "SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
    )
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(payload_before["force"].as_bool(), Some(false));

    // 2) Admin force-click.
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/pages/{page_id}/analyze"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    // 3) STILL one row (no dedup duplicate), but its `force` flag
    //    flipped to true — the admin intent landed where the worker
    //    can see it.
    let rows: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
    )
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(rows, 1, "upgrade in place; no duplicate row");

    let payload_after: serde_json::Value = sqlx::query_scalar(
        "SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
    )
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(payload_after["force"].as_bool(), Some(true));

    // 4) Audit row carries the outcome so a moderator can tell
    //    upgrade-paths from fresh-inserts.
    let audit_payload: serde_json::Value = sqlx::query_scalar(
        "SELECT payload FROM admin_audit WHERE action = 'analysis_force_page'",
    )
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(
        audit_payload["outcome"].as_str(),
        Some("upgraded_pending_to_force")
    );
}

/// The running-state branch of `force=true` collision. A worker has
/// already leased the row (state=running, payload.force=false in its
/// in-memory `lease.payload`). Admin force-clicks → my code must NOT
/// only flip the row's payload to force=true (the worker holds the
/// stale value) but also release the in-flight lease so the worker
/// drops the stale payload and a fresh lease re-leases with the
/// updated value.
///
/// Mutation-confirmed: removing the `jobs::release` loop in
/// `enqueue_for_page` (leaving only the UPDATE) makes this test fail
/// — the row's state stays `running` until the lease expires.
#[sqlx::test(migrations = "./migrations")]
async fn force_analyze_releases_running_lease_so_worker_re_picks_force(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let ids = seed_pages(&pool, 1).await;
    let page_id = ids[0];

    // 1) Pre-existing non-force pending row.
    mangalord::repo::page_analysis::enqueue_for_page(&pool, page_id, false)
        .await
        .unwrap();

    // 2) Simulate a worker leasing the job — state moves to `running`
    //    with leased_until in the future and attempts=1. The worker
    //    process (in real life) destructures its in-memory `lease.payload`
    //    here, capturing `force=false` as a Rust local.
    let leases = mangalord::crawler::jobs::lease(
        &pool,
        Some(mangalord::crawler::jobs::KIND_ANALYZE_PAGE),
        1,
        std::time::Duration::from_secs(300),
    )
    .await
    .unwrap();
    assert_eq!(leases.len(), 1, "test setup: should have leased the seeded job");
    let leased_id = leases[0].id;
    // Sanity: row is now running with force=false in payload.
    let (state, force): (String, bool) = sqlx::query_as(
        "SELECT state, (payload->>'force')::boolean \
           FROM crawler_jobs WHERE id = $1",
    )
    .bind(leased_id)
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(state, "running");
    assert!(!force);

    // 3) Admin force-clicks. Without 0.87.20's release loop, the row's
    //    payload flips to force=true but state stays `running`; the
    //    worker (holding its stale `force=false` local) acks done and
    //    the page is never re-analyzed. With the release loop, the row
    //    goes back to `pending`.
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/pages/{page_id}/analyze"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    // 4) Row is back to pending, with the payload's force flag flipped
    //    to true. A subsequent worker lease would now see `force=true`
    //    and re-analyze. attempts is decremented by `jobs::release` so
    //    the page doesn't burn its retry budget on the admin click.
    let (state, force, attempts): (String, bool, i32) = sqlx::query_as(
        "SELECT state, (payload->>'force')::boolean, attempts \
           FROM crawler_jobs WHERE id = $1",
    )
    .bind(leased_id)
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(
        state, "pending",
        "running lease must be released so the worker drops its stale-payload local"
    );
    assert!(force, "payload's force flag must be flipped to true");
    assert_eq!(
        attempts, 0,
        "release refunds the attempt — the admin click doesn't burn the retry budget"
    );
}

#[sqlx::test(migrations = "./migrations")]
async fn force_analyze_unknown_page_is_404(pool: PgPool) {
    let h = common::harness_with_analysis(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/admin/pages/{}/analyze", uuid::Uuid::new_v4()),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// ---- coverage / inspection -------------------------------------------------

async fn get_json(h: &common::Harness, cookie: &str, uri: &str) -> serde_json::Value {
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(uri, cookie))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed");
    common::body_json(resp).await
}

/// Seed a manga (1 chapter, 2 pages), analyze the first, return ids.
async fn seed_partial(pool: &PgPool) -> (uuid::Uuid, uuid::Uuid, uuid::Uuid, uuid::Uuid) {
    use mangalord::domain::page_analysis::{OcrResult, SafetyFlag, VisionAnalysis};
    let (manga_id, chapter_id, ids) = seed_manga_chapter_pages(pool, 2).await;
    repo::page_analysis::persist_analysis(
        pool,
        ids[0],
        &VisionAnalysis {
            ocr_results: vec![OcrResult {
                text: "Hello".into(),
                kind: "speech".into(),
                y: None,
            }],
            tagging_results: vec!["action".into(), "city".into()],
            scene_description: "A rainy street.".into(),
            safety_flag: SafetyFlag {
                is_nsfw: true,
                content_type: vec!["gore".into()],
            },
        },
        "test-model",
    )
    .await
    .unwrap();
    (manga_id, chapter_id, ids[0], ids[1])
}

#[sqlx::test(migrations = "./migrations")]
async fn coverage_mangas_reports_analyzed_over_total(pool: PgPool) {
    // Coverage works with the worker disabled — use the plain harness.
    let h = common::harness(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let (manga_id, _ch, _p1, _p2) = seed_partial(&pool).await;

    let body = get_json(&h, &cookie, "/api/v1/admin/analysis/mangas?search=M").await;
    let item = &body["items"][0];
    assert_eq!(item["manga_id"].as_str().unwrap(), manga_id.to_string());
    assert_eq!(item["total_pages"], 2);
    assert_eq!(item["analyzed_pages"], 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn coverage_chapters_lists_per_chapter_counts(pool: PgPool) {
    let h = common::harness(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let (manga_id, chapter_id, _p1, _p2) = seed_partial(&pool).await;

    let body = get_json(
        &h,
        &cookie,
        &format!("/api/v1/admin/analysis/mangas/{manga_id}/chapters"),
    )
    .await;
    let item = &body["items"][0];
    assert_eq!(item["chapter_id"].as_str().unwrap(), chapter_id.to_string());
    assert_eq!(item["total_pages"], 2);
    assert_eq!(item["analyzed_pages"], 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_pages_reports_per_page_status(pool: PgPool) {
    let h = common::harness(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let (_m, chapter_id, p1, p2) = seed_partial(&pool).await;

    let body = get_json(
        &h,
        &cookie,
        &format!("/api/v1/admin/analysis/chapters/{chapter_id}/pages"),
    )
    .await;
    let items = body["items"].as_array().unwrap();
    assert_eq!(items.len(), 2);
    let status_for = |id: uuid::Uuid| {
        items
            .iter()
            .find(|i| i["page_id"].as_str().unwrap() == id.to_string())
            .unwrap()["status"]
            .as_str()
            .unwrap()
            .to_string()
    };
    assert_eq!(status_for(p1), "done");
    assert_eq!(status_for(p2), "none");
}

#[sqlx::test(migrations = "./migrations")]
async fn page_detail_returns_full_result(pool: PgPool) {
    let h = common::harness(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let (_m, _ch, p1, p2) = seed_partial(&pool).await;

    let d = get_json(&h, &cookie, &format!("/api/v1/admin/analysis/pages/{p1}")).await;
    assert_eq!(d["status"], "done");
    assert_eq!(d["is_nsfw"], true);
    assert_eq!(d["scene_description"], "A rainy street.");
    assert_eq!(d["model"], "test-model");
    assert_eq!(d["ocr"][0]["kind"], "speech");
    assert_eq!(d["ocr"][0]["text"], "Hello");
    let tags: Vec<&str> = d["tags"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect();
    assert!(tags.contains(&"action") && tags.contains(&"city"));
    assert_eq!(d["content_warnings"][0], "gore");

    // Unanalyzed page → status "none", empty result.
    let d2 = get_json(&h, &cookie, &format!("/api/v1/admin/analysis/pages/{p2}")).await;
    assert_eq!(d2["status"], "none");
    assert_eq!(d2["ocr"].as_array().unwrap().len(), 0);
    assert_eq!(d2["tags"].as_array().unwrap().len(), 0);
}

#[sqlx::test(migrations = "./migrations")]
async fn page_detail_unknown_page_is_404(pool: PgPool) {
    let h = common::harness(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/admin/analysis/pages/{}", uuid::Uuid::new_v4()),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn status_stream_is_admin_gated_and_event_stream(pool: PgPool) {
    let h = common::harness(pool.clone());

    // Non-admin → 403 (don't consume the streaming body).
    let (_u, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/status/stream",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);

    // Admin → 200 with an SSE content type.
    let admin_cookie = seed_admin(&pool, &h.app).await;
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/status/stream",
            &admin_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let ct = resp
        .headers()
        .get(axum::http::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or_default();
    assert!(ct.starts_with("text/event-stream"), "got content-type {ct:?}");
}

#[sqlx::test(migrations = "./migrations")]
async fn coverage_requires_admin(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_u, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/mangas",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

// ---------------------------------------------------------------------------
// Analysis history: terminal-outcome log, searchable + filterable.
// ---------------------------------------------------------------------------

/// Seed a manga → chapter → one page, then write a terminal `page_analysis`
/// row for that page. Returns the page id.
async fn seed_analyzed_page(
    pool: &PgPool,
    title: &str,
    status: &str,
    is_nsfw: bool,
    error: Option<&str>,
) -> uuid::Uuid {
    let manga_id: uuid::Uuid =
        sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
            .bind(title)
            .fetch_one(pool)
            .await
            .unwrap();
    let chapter_id: uuid::Uuid = sqlx::query_scalar(
        "INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
    )
    .bind(manga_id)
    .fetch_one(pool)
    .await
    .unwrap();
    let page_id: uuid::Uuid = sqlx::query_scalar(
        "INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
         VALUES ($1, 1, 'k', 'image/png') RETURNING id",
    )
    .bind(chapter_id)
    .fetch_one(pool)
    .await
    .unwrap();
    sqlx::query(
        "INSERT INTO page_analysis (page_id, status, is_nsfw, model, error, analyzed_at) \
         VALUES ($1, $2, $3, 'vis', $4, now())",
    )
    .bind(page_id)
    .bind(status)
    .bind(is_nsfw)
    .bind(error)
    .execute(pool)
    .await
    .unwrap();
    page_id
}

#[sqlx::test(migrations = "./migrations")]
async fn analysis_history_lists_filters_and_paginates(pool: PgPool) {
    seed_analyzed_page(&pool, "Alpha", "done", false, None).await;
    seed_analyzed_page(&pool, "Beta", "failed", false, Some("boom")).await;
    seed_analyzed_page(&pool, "Gamma", "done", true, None).await;
    // A still-pending row must NOT appear in history (terminal outcomes only).
    let pending_page = seed_analyzed_page(&pool, "Delta", "pending", false, None).await;
    let _ = pending_page;

    let h = common::harness(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;

    // Unfiltered: the three terminal rows, pending excluded.
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/history",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["page"]["total"], 3);

    // Filter by status=failed.
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/history?status=failed",
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["page"]["total"], 1);
    assert_eq!(body["items"][0]["manga_title"], "Beta");
    assert_eq!(body["items"][0]["error"], "boom");

    // NSFW-only.
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/history?nsfw=true",
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["page"]["total"], 1);
    assert_eq!(body["items"][0]["manga_title"], "Gamma");

    // Search by manga title.
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/history?search=Alpha",
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["page"]["total"], 1);
    assert_eq!(body["items"][0]["manga_title"], "Alpha");
    assert_eq!(body["items"][0]["status"], "done");

    // Pagination: limit clamps the slice; total reflects the full set.
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/history?limit=1",
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["items"].as_array().unwrap().len(), 1);
    assert_eq!(body["page"]["total"], 3);
}

#[sqlx::test(migrations = "./migrations")]
async fn analysis_history_requires_admin(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_u, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/history",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

// ---------------------------------------------------------------------------
// Analysis metrics: durations + averages + by-model + success.
// ---------------------------------------------------------------------------

/// Seed a manga→chapter→page + a terminal page_analysis row carrying a
/// duration and model. Returns the page id.
async fn seed_timed_page(
    pool: &PgPool,
    title: &str,
    status: &str,
    model: &str,
    duration_ms: i64,
) -> uuid::Uuid {
    let manga_id: uuid::Uuid =
        sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
            .bind(title)
            .fetch_one(pool)
            .await
            .unwrap();
    let chapter_id: uuid::Uuid = sqlx::query_scalar(
        "INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
    )
    .bind(manga_id)
    .fetch_one(pool)
    .await
    .unwrap();
    let page_id: uuid::Uuid = sqlx::query_scalar(
        "INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
         VALUES ($1, 1, 'k', 'image/png') RETURNING id",
    )
    .bind(chapter_id)
    .fetch_one(pool)
    .await
    .unwrap();
    sqlx::query(
        "INSERT INTO page_analysis (page_id, status, model, duration_ms, analyzed_at) \
         VALUES ($1, $2, $3, $4, now())",
    )
    .bind(page_id)
    .bind(status)
    .bind(model)
    .bind(duration_ms)
    .execute(pool)
    .await
    .unwrap();
    page_id
}

#[sqlx::test(migrations = "./migrations")]
async fn analysis_metrics_over_http(pool: PgPool) {
    seed_timed_page(&pool, "Alpha", "done", "qwen", 2000).await;
    seed_timed_page(&pool, "Beta", "done", "qwen", 4000).await;
    seed_timed_page(&pool, "Gamma", "done", "llava", 3000).await;
    seed_timed_page(&pool, "Delta", "failed", "qwen", 1000).await;

    let h = common::harness(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/metrics",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["n"], 4);
    assert_eq!(body["ok"], 3);
    assert_eq!(body["failed"], 1);
    // Headline mean is done-only (2000 + 4000 + 3000)/3 — the failed row's
    // 1000ms is excluded so it reconciles with the by-model breakdown.
    assert_eq!(body["avg_ms"], 3000.0);

    // by_model: qwen has the done rows 2000 + 4000 → avg 3000, n 2
    // (the failed qwen row is excluded — done only).
    let by_model = body["by_model"].as_array().unwrap();
    let qwen = by_model.iter().find(|m| m["model"] == "qwen").unwrap();
    assert_eq!(qwen["n"], 2);
    assert_eq!(qwen["avg_ms"], 3000.0);
}

#[sqlx::test(migrations = "./migrations")]
async fn analysis_history_carries_duration(pool: PgPool) {
    seed_timed_page(&pool, "Alpha", "done", "qwen", 2500).await;
    let h = common::harness(pool.clone());
    let cookie = seed_admin(&pool, &h.app).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/analysis/history",
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["items"][0]["duration_ms"], 2500);
}
