mod common;

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

#[sqlx::test(migrations = "./migrations")]
async fn create_then_list_returns_only_own(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie_a) = common::register_user(&h.app).await;
    let (_, cookie_b) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await;

    // User A bookmarks the manga.
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie_a,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let body = common::body_json(resp).await;
    assert_eq!(body["manga_id"], manga_id.to_string());

    // User B sees nothing.
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_b))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["items"], json!([]));

    // User A sees their bookmark.
    let resp = h
        .app
        .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie_a))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0]["manga_id"], manga_id.to_string());
}

#[sqlx::test(migrations = "./migrations")]
async fn create_returns_409_on_duplicate_manga_level(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;

    let make = || {
        common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        )
    };
    let first = h.app.clone().oneshot(make()).await.unwrap();
    assert_eq!(first.status(), StatusCode::CREATED);
    let second = h.app.oneshot(make()).await.unwrap();
    assert_eq!(second.status(), StatusCode::CONFLICT);
    let body = common::body_json(second).await;
    assert_eq!(body["error"]["code"], "conflict");
}

#[sqlx::test(migrations = "./migrations")]
async fn create_404_on_unknown_manga(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let unknown = Uuid::nil();
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": unknown.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_non_positive_page_with_422(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string(), "page": 0 }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "validation_failed");
    assert!(body["error"]["details"]["page"].is_string());

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string(), "page": -1 }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_requires_authentication(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;

    // Unauthenticated request → 401.
    let resp = h
        .app
        .oneshot(common::post_json(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[sqlx::test(migrations = "./migrations")]
async fn user_a_cannot_delete_user_b_bookmark(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie_a) = common::register_user(&h.app).await;
    let (_, cookie_b) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await;

    // User A creates a bookmark.
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie_a,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let id = body["id"].as_str().unwrap().to_string();

    // User B tries to delete → 403.
    let resp = h
        .app
        .clone()
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/bookmarks/{id}"),
            &cookie_b,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "forbidden");

    // User A succeeds.
    let resp = h
        .app
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/bookmarks/{id}"),
            &cookie_a,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[sqlx::test(migrations = "./migrations")]
async fn concurrent_manga_bookmarks_serialised_by_unique_index(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;

    let app_a = h.app.clone();
    let cookie_a = cookie.clone();
    let mid_a = manga_id.to_string();
    let app_b = h.app.clone();
    let cookie_b = cookie.clone();
    let mid_b = manga_id.to_string();

    let f1 = tokio::spawn(async move {
        app_a
            .oneshot(common::post_json_with_cookie(
                "/api/v1/bookmarks",
                json!({ "manga_id": mid_a }),
                &cookie_a,
            ))
            .await
            .unwrap()
            .status()
    });
    let f2 = tokio::spawn(async move {
        app_b
            .oneshot(common::post_json_with_cookie(
                "/api/v1/bookmarks",
                json!({ "manga_id": mid_b }),
                &cookie_b,
            ))
            .await
            .unwrap()
            .status()
    });

    let (s1, s2) = tokio::join!(f1, f2);
    let statuses = [s1.unwrap(), s2.unwrap()];
    assert!(
        statuses.contains(&StatusCode::CREATED),
        "expected one winner with 201, got {statuses:?}"
    );
    assert!(
        statuses.contains(&StatusCode::CONFLICT),
        "expected one loser with 409 (the partial unique index), got {statuses:?}"
    );
}

#[sqlx::test(migrations = "./migrations")]
async fn bookmark_create_accepts_bearer_token(pool: PgPool) {
    // Bot scripts use Authorization: Bearer; cover that path on a
    // *write* endpoint to make sure CurrentUser resolves identically
    // whether the credential is a cookie or a bearer.
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/auth/tokens",
            json!({ "name": "ci-bot" }),
            &cookie,
        ))
        .await
        .unwrap();
    let bearer = common::body_json(resp).await["bearer"]
        .as_str()
        .unwrap()
        .to_string();

    let resp = h
        .app
        .oneshot(common::post_json_with_bearer(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &bearer,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
}

#[sqlx::test(migrations = "./migrations")]
async fn delete_unknown_bookmark_is_404(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::delete_with_cookie(
            "/api/v1/bookmarks/00000000-0000-0000-0000-000000000000",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn delete_already_deleted_bookmark_is_404(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();
    let id = common::body_json(resp).await["id"].as_str().unwrap().to_string();

    let resp = h
        .app
        .clone()
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/bookmarks/{id}"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    // Deleting again → 404, not 500.
    let resp = h
        .app
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/bookmarks/{id}"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn list_me_requires_authentication(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h
        .app
        .oneshot(common::get("/api/v1/me/bookmarks"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[sqlx::test(migrations = "./migrations")]
async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
    // Seed a chapter directly so we know its number without uploading pages.
    mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand"), None)
        .await
        .unwrap();
    // Look up its id so we can bookmark it.
    let chapter_id: Uuid = sqlx::query_scalar(
        "SELECT id FROM chapters WHERE manga_id = $1 AND number = $2",
    )
    .bind(manga_id)
    .bind(7_i32)
    .fetch_one(&pool)
    .await
    .unwrap();

    // Bookmark the chapter at page 4.
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({
                "manga_id": manga_id.to_string(),
                "chapter_id": chapter_id.to_string(),
                "page": 4
            }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    // Also bookmark the manga at the manga level — chapter_number must
    // come back null for it, populated for the chapter one.
    let _ = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();

    let resp = h
        .app
        .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().unwrap();
    assert_eq!(items.len(), 2);

    let chapter_bookmark = items
        .iter()
        .find(|b| !b["chapter_id"].is_null())
        .expect("chapter-level bookmark present");
    assert_eq!(chapter_bookmark["chapter_number"], 7);
    assert_eq!(chapter_bookmark["page"], 4);
    // Manga title is JOINed in too so the /bookmarks page can render
    // a real card instead of "Manga bookmark".
    assert_eq!(chapter_bookmark["manga_title"], "Berserk");
    // No cover was uploaded for the seeded manga — null is correct.
    assert!(chapter_bookmark["manga_cover_image_path"].is_null());

    let manga_bookmark = items
        .iter()
        .find(|b| b["chapter_id"].is_null())
        .expect("manga-level bookmark present");
    assert!(
        manga_bookmark["chapter_number"].is_null(),
        "manga-level bookmark must have a null chapter_number"
    );
    assert_eq!(manga_bookmark["manga_title"], "Berserk");
}

#[sqlx::test(migrations = "./migrations")]
async fn list_me_returns_paged_envelope(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert!(body["items"].is_array());
    assert_eq!(body["page"]["limit"], 50);
    assert_eq!(body["page"]["offset"], 0);
    // `total` is the unfiltered row count, returned so callers (e.g.
    // the profile overview's bookmark counter) can show a number
    // without paging through.
    assert_eq!(body["page"]["total"], 0);
}

// -------------------------------------------------------------------------
// Bookmark create -> SyncChapterContent job enqueue (background task)
// -------------------------------------------------------------------------

async fn seed_chapter_with_source(
    pool: &PgPool,
    manga_id: Uuid,
    number: i32,
    source_id: &str,
    source_chapter_key: &str,
    source_url: &str,
    dropped: bool,
) -> Uuid {
    let chapter_id: Uuid =
        mangalord::repo::chapter::create(pool, manga_id, number, None, None)
            .await
            .unwrap()
            .id;
    sqlx::query("INSERT INTO sources (id, name, base_url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
        .bind(source_id)
        .bind(source_id)
        .bind("https://example.com")
        .execute(pool)
        .await
        .unwrap();
    let dropped_at = if dropped { "now()" } else { "NULL" };
    sqlx::query(&format!(
        "INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url, dropped_at) \
         VALUES ($1, $2, $3, $4, {dropped_at})"
    ))
    .bind(source_id)
    .bind(source_chapter_key)
    .bind(chapter_id)
    .bind(source_url)
    .execute(pool)
    .await
    .unwrap();
    chapter_id
}

/// Poll `crawler_jobs` for the expected pending count, up to ~1.5s, so the
/// detached `tokio::spawn` from the bookmark create handler has time to
/// land regardless of CI scheduling jitter.
async fn wait_for_pending_count(pool: &PgPool, expected: i64) -> i64 {
    for _ in 0..30 {
        let count: i64 = sqlx::query_scalar(
            "SELECT COUNT(*) FROM crawler_jobs \
              WHERE state = 'pending' \
                AND payload->>'kind' = 'sync_chapter_content'",
        )
        .fetch_one(pool)
        .await
        .unwrap();
        if count >= expected {
            return count;
        }
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    }
    sqlx::query_scalar::<_, i64>(
        "SELECT COUNT(*) FROM crawler_jobs \
          WHERE state = 'pending' \
            AND payload->>'kind' = 'sync_chapter_content'",
    )
    .fetch_one(pool)
    .await
    .unwrap()
}

#[sqlx::test(migrations = "./migrations")]
async fn create_enqueues_sync_chapter_content_jobs_for_pending_chapters(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;

    // Two zero-page chapters with non-dropped sources.
    let c1 = seed_chapter_with_source(&pool, manga_id, 1, "target", "ch1", "https://example.com/c1", false).await;
    let c2 = seed_chapter_with_source(&pool, manga_id, 2, "target", "ch2", "https://example.com/c2", false).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    let count = wait_for_pending_count(&pool, 2).await;
    assert_eq!(count, 2, "both pending chapters should be enqueued");

    let chapter_ids: Vec<String> = sqlx::query_scalar(
        "SELECT payload->>'chapter_id' FROM crawler_jobs \
          WHERE payload->>'kind' = 'sync_chapter_content' \
          ORDER BY payload->>'chapter_id'",
    )
    .fetch_all(&pool)
    .await
    .unwrap();
    let mut expected = vec![c1.to_string(), c2.to_string()];
    expected.sort();
    assert_eq!(chapter_ids, expected);
}

#[sqlx::test(migrations = "./migrations")]
async fn re_bookmark_after_delete_does_not_re_enqueue_pending_jobs(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
    let _ = seed_chapter_with_source(&pool, manga_id, 1, "target", "ch1", "https://example.com/c1", false).await;

    // First bookmark — should enqueue 1.
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();
    let bookmark_id = common::body_json(resp).await["id"].as_str().unwrap().to_string();
    assert_eq!(wait_for_pending_count(&pool, 1).await, 1);

    // Delete the bookmark, then re-bookmark — the existing pending job
    // is still there so the dedup index suppresses the second enqueue.
    let resp = h
        .app
        .clone()
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/bookmarks/{bookmark_id}"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    // Give the background task time to attempt re-enqueue (it should be a no-op).
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
    let final_count: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM crawler_jobs \
          WHERE state IN ('pending', 'running') \
            AND payload->>'kind' = 'sync_chapter_content'",
    )
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(final_count, 1, "dedup index keeps the queue at a single in-flight row");
}

#[sqlx::test(migrations = "./migrations")]
async fn create_skips_chapters_with_dropped_sources(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;

    let _alive = seed_chapter_with_source(&pool, manga_id, 1, "target", "ch1", "https://example.com/c1", false).await;
    let _dropped = seed_chapter_with_source(&pool, manga_id, 2, "target", "ch2", "https://example.com/c2", true).await;

    let resp = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/bookmarks",
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    assert_eq!(
        wait_for_pending_count(&pool, 1).await,
        1,
        "only the chapter with a non-dropped source row gets enqueued"
    );
}
