//! Integration tests for the page-tag endpoints:
//!
//! - `POST   /v1/pages/:id/tags`
//! - `DELETE /v1/pages/:id/tags/:tag`
//! - `GET    /v1/pages/:id/my-tags`
//! - `GET    /v1/me/page-tags`
//! - `GET    /v1/me/page-tags/distinct`
//! - `GET    /v1/me/page-tags/chapters`
//! - `GET    /v1/me/page-tags/mangas`
//!
//! Together with `repo::page_tag` and migration 0023's `page_tags`
//! table. Validation behaviour and the page-deletion cascade are
//! pinned here.

mod common;

use axum::http::StatusCode;
use serde_json::{json, Value};
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;

use common::MultipartBuilder;

async fn seed_chapter_with_page(
    app: &axum::Router,
    cookie: &str,
    manga_id: Uuid,
    number: i32,
) -> (String, String) {
    let resp = app
        .clone()
        .oneshot(common::post_multipart_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/chapters"),
            MultipartBuilder::new()
                .add_json("metadata", json!({ "number": number }))
                .add_file("page", "1.png", "image/png", &common::fake_png_bytes()),
            cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let chapter = common::body_json(resp).await;
    let chapter_id = chapter["id"].as_str().unwrap().to_string();

    let resp = app
        .clone()
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"),
            cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let page_id = body["pages"][0]["id"].as_str().unwrap().to_string();
    (chapter_id, page_id)
}

/// Like `seed_chapter_with_page` but uploads `n` pages so the
/// aggregation tests can tag a subset and verify match counts.
/// Returns `(chapter_id, page_ids_in_order)`.
async fn seed_chapter_with_n_pages(
    app: &axum::Router,
    cookie: &str,
    manga_id: Uuid,
    number: i32,
    n: usize,
) -> (String, Vec<String>) {
    let mut builder = MultipartBuilder::new()
        .add_json("metadata", json!({ "number": number }));
    for i in 0..n {
        let fname = format!("{:04}.png", i + 1);
        builder = builder.add_file("page", &fname, "image/png", &common::fake_png_bytes());
    }
    let resp = app
        .clone()
        .oneshot(common::post_multipart_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/chapters"),
            builder,
            cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let chapter = common::body_json(resp).await;
    let chapter_id = chapter["id"].as_str().unwrap().to_string();

    let resp = app
        .clone()
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/chapters/{chapter_id}/pages"),
            cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let page_ids: Vec<String> = body["pages"]
        .as_array()
        .unwrap()
        .iter()
        .map(|p| p["id"].as_str().unwrap().to_string())
        .collect();
    (chapter_id, page_ids)
}

async fn add_tag(
    app: &axum::Router,
    cookie: &str,
    page_id: &str,
    tag: &str,
) -> StatusCode {
    let resp = app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/pages/{page_id}/tags"),
            json!({ "tag": tag }),
            cookie,
        ))
        .await
        .unwrap();
    resp.status()
}

#[sqlx::test(migrations = "./migrations")]
async fn add_then_list_returns_normalized_tag(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, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    assert_eq!(add_tag(&h.app, &cookie, &page_id, "  Funny  ").await, StatusCode::CREATED);

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/pages/{page_id}/my-tags"),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    // Stored as normalized "funny".
    assert_eq!(body["tags"], json!(["funny"]));
}

#[sqlx::test(migrations = "./migrations")]
async fn add_is_idempotent_picks_201_then_200(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, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
    // Even after re-casing, the normalized form collides → 200.
    assert_eq!(add_tag(&h.app, &cookie, &page_id, "FUNNY").await, StatusCode::OK);
}

#[sqlx::test(migrations = "./migrations")]
async fn add_unknown_page_is_404(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/pages/{}/tags", Uuid::new_v4()),
            json!({ "tag": "funny" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn add_blank_tag_is_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, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/pages/{page_id}/tags"),
            json!({ "tag": "   " }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "./migrations")]
async fn add_tag_too_long_is_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, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    let long = "a".repeat(65);
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/pages/{page_id}/tags"),
            json!({ "tag": long }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "./migrations")]
async fn add_tag_with_invisible_format_char_is_422(pool: PgPool) {
    // Without the format-char guard, `"funny"` and `"funny\u{200d}"`
    // (zero-width joiner appended) would be stored as distinct rows,
    // visually identical to the user and indistinguishable in the
    // chip cloud. Same risk for bidi overrides and BOM.
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    for bad in [
        "funny\u{200d}", // ZWJ
        "a\u{202e}b",    // RLO
        "a\u{feff}b",    // ZWNBSP / BOM
        "a\u{200b}b",    // ZWSP
    ] {
        let resp = h
            .app
            .clone()
            .oneshot(common::post_json_with_cookie(
                &format!("/api/v1/pages/{page_id}/tags"),
                json!({ "tag": bad }),
                &cookie,
            ))
            .await
            .unwrap();
        assert_eq!(
            resp.status(),
            StatusCode::UNPROCESSABLE_ENTITY,
            "expected 422 for tag containing invisible char (bytes: {:?})",
            bad.as_bytes()
        );
    }
}

#[sqlx::test(migrations = "./migrations")]
async fn add_tag_with_path_breaking_char_is_422(pool: PgPool) {
    // Without this guard, `"a/b"` would be storable via POST (JSON
    // body) but unremovable via DELETE (axum decodes %2F back to `/`
    // and the `:tag` segment never matches). The 422 keeps every
    // stored tag round-trippable.
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    for bad in ["a/b", "a?b", "a#b", "a%b", "a_b", "a\\b"] {
        let resp = h
            .app
            .clone()
            .oneshot(common::post_json_with_cookie(
                &format!("/api/v1/pages/{page_id}/tags"),
                json!({ "tag": bad }),
                &cookie,
            ))
            .await
            .unwrap();
        assert_eq!(
            resp.status(),
            StatusCode::UNPROCESSABLE_ENTITY,
            "expected 422 for tag {bad:?}"
        );
    }
}

#[sqlx::test(migrations = "./migrations")]
async fn add_tag_with_control_char_is_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, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/pages/{page_id}/tags"),
            json!({ "tag": "bad\ntag" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "./migrations")]
async fn remove_normalizes_url_tag(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, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);

    // DELETE arrives with the original case; the handler renormalizes
    // so it still matches "funny" in storage.
    let resp = h
        .app
        .clone()
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/pages/{page_id}/tags/FUNNY"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/pages/{page_id}/my-tags"),
            &cookie,
        ))
        .await
        .unwrap();
    let body: Value = common::body_json(resp).await;
    assert_eq!(body["tags"], json!([]));
}

#[sqlx::test(migrations = "./migrations")]
async fn list_mine_filters_by_tag(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, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
    assert_eq!(add_tag(&h.app, &cookie, &page_id, "fight").await, StatusCode::CREATED);
    assert_eq!(add_tag(&h.app, &cookie, &page_id, "panel-of-the-day").await, StatusCode::CREATED);

    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie("/api/v1/me/page-tags?tag=fight", &cookie))
        .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]["tag"], "fight");
    assert_eq!(items[0]["page_id"], page_id);
    assert_eq!(items[0]["manga_title"], "M");

    // Prefix filter ?q=fu matches "funny".
    let resp = h
        .app
        .oneshot(common::get_with_cookie("/api/v1/me/page-tags?q=fu", &cookie))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let tags: Vec<&str> = body["items"]
        .as_array()
        .unwrap()
        .iter()
        .map(|v| v["tag"].as_str().unwrap())
        .collect();
    assert_eq!(tags, vec!["funny"]);
}

#[sqlx::test(migrations = "./migrations")]
async fn distinct_lists_counts_per_tag(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, "M").await;
    let (_, p1) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
    let (_, p2) = seed_chapter_with_page(&h.app, &cookie, manga_id, 2).await;

    // "funny" appears twice; "fight" once. Distinct should reflect that.
    assert_eq!(add_tag(&h.app, &cookie, &p1, "funny").await, StatusCode::CREATED);
    assert_eq!(add_tag(&h.app, &cookie, &p2, "funny").await, StatusCode::CREATED);
    assert_eq!(add_tag(&h.app, &cookie, &p1, "fight").await, StatusCode::CREATED);

    let resp = h
        .app
        .oneshot(common::get_with_cookie("/api/v1/me/page-tags/distinct", &cookie))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().unwrap();
    // Ordered by count DESC, tag ASC.
    assert_eq!(items[0]["tag"], "funny");
    assert_eq!(items[0]["count"], 2);
    assert_eq!(items[1]["tag"], "fight");
    assert_eq!(items[1]["count"], 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn tags_are_per_user_only(pool: PgPool) {
    let h = common::harness(pool);
    let (_, a) = common::register_user(&h.app).await;
    let (_, b) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &a, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &a, manga_id, 1).await;

    assert_eq!(add_tag(&h.app, &a, &page_id, "funny").await, StatusCode::CREATED);

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/pages/{page_id}/my-tags"),
            &b,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["tags"], json!([]));
}

#[sqlx::test(migrations = "./migrations")]
async fn page_tag_and_manga_tag_share_one_tags_row(pool: PgPool) {
    // The point of the normalization refactor: page tags now reference
    // the shared `tags` table. A manga tag "Funny" and a page tag
    // "funny" (case-folded) must resolve to a single `tags` row, and
    // `page_tags.tag_id` must point at the same row as `manga_tags`.
    let db = pool.clone();
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

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

    assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);

    let (tag_count,): (i64,) =
        sqlx::query_as("SELECT count(*) FROM tags WHERE lower(name) = 'funny'")
            .fetch_one(&db)
            .await
            .unwrap();
    assert_eq!(tag_count, 1, "page tag and manga tag must share one tags row");

    let (shared,): (bool,) = sqlx::query_as(
        "SELECT (SELECT tag_id FROM page_tags LIMIT 1) \
                = (SELECT tag_id FROM manga_tags LIMIT 1)",
    )
    .fetch_one(&db)
    .await
    .unwrap();
    assert!(
        shared,
        "page_tags.tag_id must reference the same row as manga_tags.tag_id"
    );
}

#[sqlx::test(migrations = "./migrations")]
async fn adding_a_page_tag_creates_a_shared_tags_row(pool: PgPool) {
    // Even with no manga tag in play, adding a page tag must populate
    // the shared `tags` lookup table (one row per distinct name).
    let db = pool.clone();
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;

    assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);

    let (name,): (String,) =
        sqlx::query_as("SELECT name FROM tags WHERE lower(name) = 'funny'")
            .fetch_one(&db)
            .await
            .unwrap();
    assert_eq!(name, "funny");
}

#[sqlx::test(migrations = "./migrations")]
async fn tags_require_authentication(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h
        .app
        .oneshot(common::post_json(
            &format!("/api/v1/pages/{}/tags", Uuid::new_v4()),
            json!({ "tag": "funny" }),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

// -------------------------------------------------------------------
// Aggregation endpoints (`/me/page-tags/{chapters,mangas}`).
// -------------------------------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn chapters_aggregate_groups_and_ranks_by_match_count(pool: PgPool) {
    // Chapter A in manga Berserk gets 3 pages tagged "funny";
    // chapter B (same manga) gets 1. Desc order → A first.
    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 (chapter_a, pages_a) =
        seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await;
    let (chapter_b, pages_b) =
        seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 2).await;

    for p in &pages_a {
        assert_eq!(add_tag(&h.app, &cookie, p, "funny").await, StatusCode::CREATED);
    }
    assert_eq!(add_tag(&h.app, &cookie, &pages_b[0], "funny").await, StatusCode::CREATED);

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/me/page-tags/chapters?tag=funny",
            &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]["chapter_id"], chapter_a);
    assert_eq!(items[0]["match_count"], 3);
    // Up to 3 sample storage keys, ordered by page_number ASC.
    let samples_a = items[0]["sample_storage_keys"].as_array().unwrap();
    assert_eq!(samples_a.len(), 3);
    assert_eq!(items[1]["chapter_id"], chapter_b);
    assert_eq!(items[1]["match_count"], 1);
    assert_eq!(body["page"]["total"], 2);
}

#[sqlx::test(migrations = "./migrations")]
async fn chapters_aggregate_respects_asc_order(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, "M").await;
    let (chapter_a, pages_a) =
        seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await;
    let (chapter_b, pages_b) =
        seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 1).await;
    for p in &pages_a {
        let _ = add_tag(&h.app, &cookie, p, "funny").await;
    }
    let _ = add_tag(&h.app, &cookie, &pages_b[0], "funny").await;

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/me/page-tags/chapters?tag=funny&order=asc",
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().unwrap();
    // Lowest count first.
    assert_eq!(items[0]["chapter_id"], chapter_b);
    assert_eq!(items[0]["match_count"], 1);
    assert_eq!(items[1]["chapter_id"], chapter_a);
    assert_eq!(items[1]["match_count"], 3);
}

#[sqlx::test(migrations = "./migrations")]
async fn mangas_aggregate_sums_across_chapters(pool: PgPool) {
    // Two chapters in the same manga, each contributes to the same
    // manga's match_count.
    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 (_, pages_a) =
        seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 3).await;
    let (_, pages_b) =
        seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 2, 2).await;
    for p in pages_a.iter().chain(pages_b.iter()) {
        let _ = add_tag(&h.app, &cookie, p, "funny").await;
    }

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/me/page-tags/mangas?tag=funny",
            &cookie,
        ))
        .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());
    assert_eq!(items[0]["match_count"], 5);
    assert_eq!(items[0]["manga_title"], "Berserk");
    let samples = items[0]["sample_storage_keys"].as_array().unwrap();
    assert_eq!(samples.len(), 3, "manga sample preview capped at 3");
}

#[sqlx::test(migrations = "./migrations")]
async fn aggregate_other_users_tags_are_excluded(pool: PgPool) {
    let h = common::harness(pool);
    let (_, a) = common::register_user(&h.app).await;
    let (_, b) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &a, "M").await;
    let (_, pages) = seed_chapter_with_n_pages(&h.app, &a, manga_id, 1, 2).await;
    // A tags everything funny; B tags nothing.
    for p in &pages {
        let _ = add_tag(&h.app, &a, p, "funny").await;
    }

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/me/page-tags/chapters?tag=funny",
            &b,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["items"], json!([]));
    assert_eq!(body["page"]["total"], 0);
}

#[sqlx::test(migrations = "./migrations")]
async fn aggregate_unknown_tag_returns_empty_paged_response(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/page-tags/chapters?tag=neverused",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["items"], json!([]));
    assert_eq!(body["page"]["total"], 0);
}

#[sqlx::test(migrations = "./migrations")]
async fn aggregate_rejects_missing_tag(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/page-tags/chapters?tag=",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "./migrations")]
async fn aggregate_rejects_invalid_order(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/page-tags/chapters?tag=funny&order=sideways",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "./migrations")]
async fn aggregate_with_text_param_filters_by_ocr(pool: PgPool) {
    // OCR text search: a page tagged `funny` whose OCR contains "guts" is
    // returned for `&text=guts` on both aggregation endpoints, and excluded
    // for a query its OCR doesn't contain. The filter runs against the same
    // precomputed `search_doc` the OCR worker writes via `persist_analysis`.
    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, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
    assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);

    // Persist OCR text exactly as the ocrs backend would (via the same mapper).
    let page_uuid = Uuid::parse_str(&page_id).unwrap();
    let analysis =
        mangalord::analysis::ocr::lines_to_analysis(vec!["spilling the guts here".to_string()]);
    mangalord::repo::page_analysis::persist_analysis(&pool, page_uuid, &analysis, "ocrs")
        .await
        .unwrap();

    for endpoint in ["chapters", "mangas"] {
        // Matching text → the tagged+OCR'd row is returned.
        let resp = h
            .app
            .clone()
            .oneshot(common::get_with_cookie(
                &format!("/api/v1/me/page-tags/{endpoint}?tag=funny&text=guts"),
                &cookie,
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK, "{endpoint} matching");
        let body = common::body_json(resp).await;
        assert_eq!(body["items"].as_array().unwrap().len(), 1, "{endpoint} matching items");
        assert_eq!(body["page"]["total"], 1, "{endpoint} matching total");

        // Non-matching text → excluded.
        let resp = h
            .app
            .clone()
            .oneshot(common::get_with_cookie(
                &format!("/api/v1/me/page-tags/{endpoint}?tag=funny&text=zzzznomatch"),
                &cookie,
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK, "{endpoint} non-matching");
        let body = common::body_json(resp).await;
        assert_eq!(body["items"].as_array().unwrap().len(), 0, "{endpoint} non-matching items");
        assert_eq!(body["page"]["total"], 0, "{endpoint} non-matching total");
    }
}

#[sqlx::test(migrations = "./migrations")]
async fn aggregate_text_param_counts_only_matching_pages(pool: PgPool) {
    // A chapter with TWO tagged pages where only ONE page's OCR matches the
    // text. `match_count` / `total` must reflect the filtered count (1), not
    // the tag-only count (2), and the sample thumbnails must contain only the
    // matching page. This is what a single-page test can't distinguish — it
    // pins the JOIN/placeholder wiring and the sample-subquery filter.
    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, "M").await;
    let (_, page_ids) = seed_chapter_with_n_pages(&h.app, &cookie, manga_id, 1, 2).await;
    for pid in &page_ids {
        assert_eq!(add_tag(&h.app, &cookie, pid, "funny").await, StatusCode::CREATED);
    }

    // Page 0 OCR contains "guts"; page 1 OCR contains only "filler".
    let p0 = Uuid::parse_str(&page_ids[0]).unwrap();
    let p1 = Uuid::parse_str(&page_ids[1]).unwrap();
    let a0 = mangalord::analysis::ocr::lines_to_analysis(vec!["the guts spill out".to_string()]);
    let a1 = mangalord::analysis::ocr::lines_to_analysis(vec!["just filler text".to_string()]);
    mangalord::repo::page_analysis::persist_analysis(&pool, p0, &a0, "ocrs").await.unwrap();
    mangalord::repo::page_analysis::persist_analysis(&pool, p1, &a1, "ocrs").await.unwrap();

    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie(
            "/api/v1/me/page-tags/chapters?tag=funny&text=guts",
            &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, "one chapter row");
    assert_eq!(body["page"]["total"], 1);
    // Filtered match_count is the matching-page count, not the tag count.
    assert_eq!(items[0]["match_count"], 1, "only one page's OCR matches");
    // Sample thumbnails reflect the filter: only the matching page's key.
    let samples = items[0]["sample_storage_keys"].as_array().unwrap();
    assert_eq!(samples.len(), 1, "thumbnails restricted to matching pages");
}

#[sqlx::test(migrations = "./migrations")]
async fn aggregate_blank_text_param_is_tag_only(pool: PgPool) {
    // A blank `text=` must not filter — it falls back to tag-only aggregation
    // (the page has a tag but no analysis row at all).
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
    let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
    assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/me/page-tags/chapters?tag=funny&text=",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["items"].as_array().unwrap().len(), 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn aggregate_requires_authentication(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h
        .app
        .oneshot(common::get("/api/v1/me/page-tags/chapters?tag=funny"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
