//! PR 3 (feat/admin-mangas-api) integration tests.
//!
//! Per-variant fixture tests for the derived sync-state SQL plus
//! happy-path E2E for the two admin endpoints. Auth-gate regression
//! (403/401) is covered by PR 1's `RequireAdmin` test matrix; the only
//! gate test here is one spot check per endpoint.

mod common;

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

use mangalord::repo;

const SOURCE_ID: &str = "test-source";

async fn seed_admin(pool: &PgPool, app: &Router) -> (String, 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();
    (username, cookie)
}

async fn seed_source(pool: &PgPool) {
    repo::crawler::ensure_source(pool, SOURCE_ID, "Test", "https://example.test")
        .await
        .unwrap();
}

async fn insert_manga(pool: &PgPool, title: &str) -> Uuid {
    let (id,): (Uuid,) = sqlx::query_as(
        "INSERT INTO mangas (title, status, alt_titles) VALUES ($1, 'ongoing', ARRAY[]::text[]) RETURNING id",
    )
    .bind(title)
    .fetch_one(pool)
    .await
    .unwrap();
    id
}

async fn insert_manga_source(
    pool: &PgPool,
    manga_id: Uuid,
    source_manga_key: &str,
    dropped: bool,
) {
    let dropped_at = if dropped { "now()" } else { "NULL" };
    let sql = format!(
        "INSERT INTO manga_sources (source_id, source_manga_key, manga_id, source_url, dropped_at) \
         VALUES ($1, $2, $3, 'https://example.test/m', {dropped_at})"
    );
    sqlx::query(&sql)
        .bind(SOURCE_ID)
        .bind(source_manga_key)
        .bind(manga_id)
        .execute(pool)
        .await
        .unwrap();
}

async fn insert_chapter(pool: &PgPool, manga_id: Uuid, number: i32, page_count: i32) -> Uuid {
    let (id,): (Uuid,) = sqlx::query_as(
        "INSERT INTO chapters (manga_id, number, title, page_count) VALUES ($1, $2, NULL, $3) RETURNING id",
    )
    .bind(manga_id)
    .bind(number)
    .bind(page_count)
    .fetch_one(pool)
    .await
    .unwrap();
    id
}

async fn insert_chapter_source(
    pool: &PgPool,
    chapter_id: Uuid,
    source_chapter_key: &str,
    dropped: bool,
) {
    let dropped_at = if dropped { "now()" } else { "NULL" };
    let sql = format!(
        "INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url, dropped_at) \
         VALUES ($1, $2, $3, 'https://example.test/c', {dropped_at})"
    );
    sqlx::query(&sql)
        .bind(SOURCE_ID)
        .bind(source_chapter_key)
        .bind(chapter_id)
        .execute(pool)
        .await
        .unwrap();
}

async fn insert_job(pool: &PgPool, payload: serde_json::Value, state: &str) {
    sqlx::query("INSERT INTO crawler_jobs (payload, state) VALUES ($1, $2)")
        .bind(payload)
        .bind(state)
        .execute(pool)
        .await
        .unwrap();
}

/// Per-variant tests don't care about pagination — fetch the whole
/// chapter set (up to the hard cap) and discard the total.
async fn fetch_chapter_rows(
    pool: &PgPool,
    manga_id: Uuid,
) -> Vec<mangalord::repo::admin_view::AdminChapterRow> {
    let (rows, _) = repo::admin_view::list_chapters_with_sync_state(
        pool,
        &repo::admin_view::ListAdminChaptersQuery {
            manga_id,
            limit: 500,
            offset: 0,
        },
    )
    .await
    .unwrap();
    rows
}

// ---- manga sync state ------------------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn manga_state_synced_for_fresh_source(pool: PgPool) {
    seed_source(&pool).await;
    let m = insert_manga(&pool, "Synced Manga").await;
    insert_manga_source(&pool, m, "smk-1", false).await;

    let (rows, total) = repo::admin_view::list_mangas_with_sync_state(
        &pool,
        &repo::admin_view::ListAdminMangasQuery {
            limit: 50,
            ..Default::default()
        },
    )
    .await
    .unwrap();
    assert_eq!(total, 1);
    assert_eq!(rows[0].id, m);
    assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Synced);
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_state_synced_for_user_upload_without_sources(pool: PgPool) {
    let m = insert_manga(&pool, "User Upload").await;
    let (rows, _) = repo::admin_view::list_mangas_with_sync_state(
        &pool,
        &repo::admin_view::ListAdminMangasQuery {
            limit: 50,
            ..Default::default()
        },
    )
    .await
    .unwrap();
    assert_eq!(rows[0].id, m);
    assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Synced);
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_state_dropped_when_all_sources_dropped(pool: PgPool) {
    seed_source(&pool).await;
    let m = insert_manga(&pool, "Dropped Manga").await;
    insert_manga_source(&pool, m, "smk-1", true).await;

    let (rows, _) = repo::admin_view::list_mangas_with_sync_state(
        &pool,
        &repo::admin_view::ListAdminMangasQuery {
            limit: 50,
            ..Default::default()
        },
    )
    .await
    .unwrap();
    assert_eq!(rows[0].id, m);
    assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::Dropped);
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_state_in_progress_via_sync_chapter_list_job(pool: PgPool) {
    seed_source(&pool).await;
    let m = insert_manga(&pool, "Syncing Manga").await;
    insert_manga_source(&pool, m, "smk-1", false).await;
    // sync_chapter_list payload carries manga_id directly.
    insert_job(
        &pool,
        json!({
            "kind": "sync_chapter_list",
            "source_id": SOURCE_ID,
            "manga_id": m.to_string(),
            "source_manga_key": "smk-1",
        }),
        "pending",
    )
    .await;

    let (rows, _) = repo::admin_view::list_mangas_with_sync_state(
        &pool,
        &repo::admin_view::ListAdminMangasQuery {
            limit: 50,
            ..Default::default()
        },
    )
    .await
    .unwrap();
    assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::InProgress);
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_state_in_progress_via_sync_manga_job(pool: PgPool) {
    // The trickier branch: sync_manga payload is keyed by
    // source_manga_key, NOT manga_id — must join through manga_sources.
    seed_source(&pool).await;
    let m = insert_manga(&pool, "Metadata-Refreshing Manga").await;
    insert_manga_source(&pool, m, "smk-key-42", false).await;
    insert_job(
        &pool,
        json!({
            "kind": "sync_manga",
            "source_id": SOURCE_ID,
            "source_manga_key": "smk-key-42",
        }),
        "running",
    )
    .await;

    let (rows, _) = repo::admin_view::list_mangas_with_sync_state(
        &pool,
        &repo::admin_view::ListAdminMangasQuery {
            limit: 50,
            ..Default::default()
        },
    )
    .await
    .unwrap();
    assert_eq!(rows[0].sync_state, mangalord::domain::MangaSyncState::InProgress);
}

#[sqlx::test(migrations = "./migrations")]
async fn manga_list_filters_by_sync_state(pool: PgPool) {
    seed_source(&pool).await;
    let m_synced = insert_manga(&pool, "AAA Synced").await;
    insert_manga_source(&pool, m_synced, "smk-a", false).await;
    let m_dropped = insert_manga(&pool, "BBB Dropped").await;
    insert_manga_source(&pool, m_dropped, "smk-b", true).await;

    let (rows, total) = repo::admin_view::list_mangas_with_sync_state(
        &pool,
        &repo::admin_view::ListAdminMangasQuery {
            sync_state: Some(mangalord::domain::MangaSyncState::Dropped),
            limit: 50,
            ..Default::default()
        },
    )
    .await
    .unwrap();
    assert_eq!(total, 1);
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].id, m_dropped);
}

// ---- chapter sync state ----------------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn chapter_state_synced_when_pages_present(pool: PgPool) {
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    insert_manga_source(&pool, m, "smk", false).await;
    let c = insert_chapter(&pool, m, 1, 12).await;
    insert_chapter_source(&pool, c, "ckey-1", false).await;

    let rows = fetch_chapter_rows(&pool, m).await;
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].id, c);
    assert_eq!(rows[0].sync_state, mangalord::domain::ChapterSyncState::Synced);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_state_not_downloaded_when_page_count_zero(pool: PgPool) {
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    let c = insert_chapter(&pool, m, 1, 0).await;
    insert_chapter_source(&pool, c, "ckey-1", false).await;

    let rows = fetch_chapter_rows(&pool, m).await;
    assert_eq!(
        rows[0].sync_state,
        mangalord::domain::ChapterSyncState::NotDownloaded
    );
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_state_downloading_when_job_in_flight(pool: PgPool) {
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    let c = insert_chapter(&pool, m, 1, 0).await;
    insert_chapter_source(&pool, c, "ckey-1", false).await;
    insert_job(
        &pool,
        json!({
            "kind": "sync_chapter_content",
            "source_id": SOURCE_ID,
            "chapter_id": c.to_string(),
            "source_chapter_key": "ckey-1",
        }),
        "running",
    )
    .await;

    let rows = fetch_chapter_rows(&pool, m).await;
    assert_eq!(
        rows[0].sync_state,
        mangalord::domain::ChapterSyncState::Downloading
    );
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_state_dropped_when_all_sources_dropped(pool: PgPool) {
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    let c = insert_chapter(&pool, m, 1, 0).await;
    insert_chapter_source(&pool, c, "ckey-1", true).await;

    let rows = fetch_chapter_rows(&pool, m).await;
    assert_eq!(
        rows[0].sync_state,
        mangalord::domain::ChapterSyncState::Dropped
    );
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_state_failed_when_most_recent_job_dead(pool: PgPool) {
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    let c = insert_chapter(&pool, m, 1, 0).await;
    insert_chapter_source(&pool, c, "ckey-1", false).await;
    insert_job(
        &pool,
        json!({
            "kind": "sync_chapter_content",
            "source_id": SOURCE_ID,
            "chapter_id": c.to_string(),
            "source_chapter_key": "ckey-1",
        }),
        "dead",
    )
    .await;

    let rows = fetch_chapter_rows(&pool, m).await;
    assert_eq!(
        rows[0].sync_state,
        mangalord::domain::ChapterSyncState::Failed
    );
}

// ---- HTTP-level happy-path + gate ------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn http_list_mangas_returns_paged_with_state(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_admin, cookie) = seed_admin(&pool, &h.app).await;
    seed_source(&pool).await;
    let m = insert_manga(&pool, "Hello").await;
    insert_manga_source(&pool, m, "smk", false).await;

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/mangas?limit=50",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0]["id"], m.to_string());
    assert_eq!(items[0]["sync_state"], "synced");
    assert_eq!(items[0]["chapter_count"], 0);
    assert_eq!(body["page"]["total"], 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn http_list_mangas_rejects_unknown_sync_state(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_admin, cookie) = seed_admin(&pool, &h.app).await;
    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/mangas?sync_state=bogus",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[sqlx::test(migrations = "./migrations")]
async fn http_list_chapters_returns_per_chapter_state(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_admin, cookie) = seed_admin(&pool, &h.app).await;
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    let c1 = insert_chapter(&pool, m, 1, 12).await;
    let c2 = insert_chapter(&pool, m, 2, 0).await;
    insert_chapter_source(&pool, c1, "ck1", false).await;
    insert_chapter_source(&pool, c2, "ck2", false).await;

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/admin/mangas/{m}/chapters"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().unwrap();
    assert_eq!(items.len(), 2);
    assert_eq!(items[0]["id"], c1.to_string());
    assert_eq!(items[0]["sync_state"], "synced");
    assert_eq!(items[1]["id"], c2.to_string());
    assert_eq!(items[1]["sync_state"], "not_downloaded");
    assert_eq!(body["page"]["total"], 2);
}

#[sqlx::test(migrations = "./migrations")]
async fn http_list_chapters_caps_limit_at_500(pool: PgPool) {
    // The handler clamps limit to [1, 500] so a long-runner with
    // thousands of chapters can't be turned into a request-stall by an
    // admin (or by a curious admin tab) just clicking expand.
    let h = common::harness(pool.clone());
    let (_admin, cookie) = seed_admin(&pool, &h.app).await;
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    for n in 1..=3 {
        let _c = insert_chapter(&pool, m, n, 0).await;
    }
    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/admin/mangas/{m}/chapters?limit=999"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["page"]["limit"], 500, "limit must clamp to 500");
    assert_eq!(body["items"].as_array().unwrap().len(), 3);
}

#[sqlx::test(migrations = "./migrations")]
async fn http_list_chapters_paginates(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_admin, cookie) = seed_admin(&pool, &h.app).await;
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    for n in 1..=5 {
        let _c = insert_chapter(&pool, m, n, 0).await;
    }

    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/admin/mangas/{m}/chapters?limit=2&offset=2"),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().unwrap();
    assert_eq!(items.len(), 2);
    // Ordered by chapter number ascending; offset=2 skips chapters 1 & 2.
    assert_eq!(items[0]["number"], 3);
    assert_eq!(items[1]["number"], 4);
    assert_eq!(body["page"]["total"], 5);
}

#[sqlx::test(migrations = "./migrations")]
async fn http_list_chapters_returns_404_for_unknown_manga(pool: PgPool) {
    // Regression: used to return 200 [] for a non-existent manga,
    // which silently rendered "No chapters." for a typo'd / deleted id.
    let h = common::harness(pool.clone());
    let (_admin, cookie) = seed_admin(&pool, &h.app).await;
    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/admin/mangas/{}/chapters", Uuid::new_v4()),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapter_state_synced_when_pages_present_even_with_dead_job(pool: PgPool) {
    // Regression: the old CASE prioritised the dead-job branch above
    // the page_count check, so a chapter with pages on disk AND a
    // historical dead job (e.g. from a re-download attempt that
    // crashed) flipped to Failed — contradicting Synced's "downloaded
    // at some point" contract.
    seed_source(&pool).await;
    let m = insert_manga(&pool, "M").await;
    let c = insert_chapter(&pool, m, 1, 12).await; // pages present
    insert_chapter_source(&pool, c, "ckey-1", false).await;
    insert_job(
        &pool,
        json!({
            "kind": "sync_chapter_content",
            "source_id": SOURCE_ID,
            "chapter_id": c.to_string(),
            "source_chapter_key": "ckey-1",
        }),
        "dead",
    )
    .await;

    let rows = fetch_chapter_rows(&pool, m).await;
    assert_eq!(
        rows[0].sync_state,
        mangalord::domain::ChapterSyncState::Synced,
        "pages on disk override historical dead-job noise"
    );
}

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