mod common;

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

use common::MultipartBuilder;

fn metadata(title: &str) -> serde_json::Value {
    json!({ "title": title })
}

/// Collect the `title` field of every item in a list response, in order.
fn title_list(body: &serde_json::Value) -> Vec<&str> {
    body["items"]
        .as_array()
        .unwrap()
        .iter()
        .map(|m| m["title"].as_str().unwrap())
        .collect()
}

/// Create a manga via the upload API, asserting it succeeded.
async fn seed(app: &axum::Router, cookie: &str, title: &str) {
    let resp = app
        .clone()
        .oneshot(common::post_multipart_with_cookie(
            "/api/v1/mangas",
            MultipartBuilder::new().add_json("metadata", json!({ "title": title })),
            cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED, "seed({title}) failed");
}

/// Pin a manga's `created_at` to an explicit RFC3339 instant so time-based
/// ordering assertions don't depend on insert timing.
async fn set_created_at(pool: &PgPool, title: &str, when: &str) {
    let ts: chrono::DateTime<chrono::Utc> = when.parse().unwrap();
    sqlx::query("UPDATE mangas SET created_at = $1 WHERE title = $2")
        .bind(ts)
        .bind(title)
        .execute(pool)
        .await
        .unwrap();
}

/// Pin a manga's `updated_at` to an explicit RFC3339 instant.
async fn set_updated_at(pool: &PgPool, title: &str, when: &str) {
    let ts: chrono::DateTime<chrono::Utc> = when.parse().unwrap();
    sqlx::query("UPDATE mangas SET updated_at = $1 WHERE title = $2")
        .bind(ts)
        .bind(title)
        .execute(pool)
        .await
        .unwrap();
}

#[sqlx::test(migrations = "./migrations")]
async fn list_is_empty_initially(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["items"], json!([]));
    assert_eq!(body["page"]["limit"], 50);
    assert_eq!(body["page"]["offset"], 0);
    assert_eq!(body["page"]["total"], 0);
}

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

    for title in ["One Piece", "Berserk", "Vinland Saga"] {
        let _ = h
            .app
            .clone()
            .oneshot(common::post_multipart_with_cookie(
                "/api/v1/mangas",
                MultipartBuilder::new().add_json("metadata", json!({ "title": title })),
                &cookie,
            ))
            .await
            .unwrap();
    }

    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?limit=2"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["items"].as_array().unwrap().len(), 2);
    // Total reflects the unfiltered population, not the page size.
    assert_eq!(body["page"]["total"], 3);
}

#[sqlx::test(migrations = "./migrations")]
async fn list_total_is_computed_only_on_the_first_page(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    for title in ["One Piece", "Berserk", "Vinland Saga"] {
        seed(&h.app, &cookie, title).await;
    }

    // Page 1 (offset 0): total is the full population.
    let body0 = common::body_json(
        h.app
            .clone()
            .oneshot(common::get("/api/v1/mangas?limit=2&offset=0"))
            .await
            .unwrap(),
    )
    .await;
    assert_eq!(body0["page"]["total"], 3);

    // Page 2 (offset 2): total is omitted (null) — the correlated count is
    // not recomputed on every page. Items still paginate correctly.
    let body1 = common::body_json(
        h.app
            .oneshot(common::get("/api/v1/mangas?limit=2&offset=2"))
            .await
            .unwrap(),
    )
    .await;
    assert!(
        body1["page"]["total"].is_null(),
        "total should be null past the first page, got {}",
        body1["page"]["total"]
    );
    assert_eq!(body1["items"].as_array().unwrap().len(), 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn search_via_trigram_tolerates_typos(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let _ = h
        .app
        .clone()
        .oneshot(common::post_multipart_with_cookie(
            "/api/v1/mangas",
            MultipartBuilder::new().add_json("metadata", json!({ "title": "Naruto" })),
            &cookie,
        ))
        .await
        .unwrap();

    // 'narto' is one letter off — the % operator on the GIN trgm index
    // should still match it.
    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?search=narto"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let titles: Vec<&str> = body["items"]
        .as_array()
        .unwrap()
        .iter()
        .map(|m| m["title"].as_str().unwrap())
        .collect();
    assert_eq!(titles, vec!["Naruto"]);
    assert_eq!(body["page"]["total"], 1);
}

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

    for title in ["Vinland Saga", "Berserk", "One Piece"] {
        let _ = h
            .app
            .clone()
            .oneshot(common::post_multipart_with_cookie(
                "/api/v1/mangas",
                MultipartBuilder::new().add_json("metadata", json!({ "title": title })),
                &cookie,
            ))
            .await
            .unwrap();
    }

    // Title sort now requires an explicit direction; the wire default is
    // `desc`, so A→Z needs `order=asc`.
    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?sort=title&order=asc"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let titles = title_list(&body);
    assert_eq!(titles, vec!["Berserk", "One Piece", "Vinland Saga"]);
}

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

    for title in ["Vinland Saga", "Berserk", "One Piece"] {
        seed(&h.app, &cookie, title).await;
    }

    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?sort=title&order=desc"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let titles = title_list(&body);
    assert_eq!(titles, vec!["Vinland Saga", "One Piece", "Berserk"]);
}

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

    for title in ["Alpha", "Bravo", "Charlie"] {
        seed(&h.app, &cookie, title).await;
    }
    // Pin explicit, distinct update times so the default ordering is
    // deterministic regardless of insert timing. Bravo is the most recently
    // updated and must appear first under the default (updated DESC).
    set_updated_at(&pool, "Alpha", "2020-01-01T00:00:00Z").await;
    set_updated_at(&pool, "Bravo", "2023-01-01T00:00:00Z").await;
    set_updated_at(&pool, "Charlie", "2021-01-01T00:00:00Z").await;

    // No sort/order params — exercises the default.
    let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap();
    let body = common::body_json(resp).await;
    let titles = title_list(&body);
    assert_eq!(titles, vec!["Bravo", "Charlie", "Alpha"]);
}

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

    for title in ["Alpha", "Bravo", "Charlie"] {
        seed(&h.app, &cookie, title).await;
    }
    set_created_at(&pool, "Alpha", "2020-01-01T00:00:00Z").await;
    set_created_at(&pool, "Bravo", "2023-01-01T00:00:00Z").await;
    set_created_at(&pool, "Charlie", "2021-01-01T00:00:00Z").await;

    // Newest-created first.
    let resp = h
        .app
        .clone()
        .oneshot(common::get("/api/v1/mangas?sort=created&order=desc"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(title_list(&body), vec!["Bravo", "Charlie", "Alpha"]);

    // Ascending is the exact reverse.
    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?sort=created&order=asc"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(title_list(&body), vec!["Alpha", "Charlie", "Bravo"]);
}

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

    // Author order is deliberately scrambled relative to title order so the
    // assertion can't pass by accidentally sorting on title.
    for (title, authors) in [
        ("Akira", json!(["Beta Author"])),
        ("Boku", json!(["Alpha Author"])),
        ("Chi", json!(["Gamma Author"])),
        ("NoAuthor", json!([])),
    ] {
        let _ = h
            .app
            .clone()
            .oneshot(common::post_multipart_with_cookie(
                "/api/v1/mangas",
                MultipartBuilder::new()
                    .add_json("metadata", json!({ "title": title, "authors": authors })),
                &cookie,
            ))
            .await
            .unwrap();
    }

    // Ascending by author name; the authorless manga sorts last (NULLS LAST).
    let resp = h
        .app
        .clone()
        .oneshot(common::get("/api/v1/mangas?sort=author&order=asc"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(title_list(&body), vec!["Boku", "Akira", "Chi", "NoAuthor"]);

    // Descending flips the authored mangas but keeps the authorless one last.
    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?sort=author&order=desc"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(title_list(&body), vec!["Chi", "Akira", "Boku", "NoAuthor"]);
}

#[sqlx::test(migrations = "./migrations")]
async fn list_sort_recent_alias_maps_to_created_desc(pool: PgPool) {
    // `recent` was the pre-0.88 sort value (newest created first). It is kept
    // as an alias for `created` so existing bots/scripts don't break.
    let h = common::harness(pool.clone());
    let (_, cookie) = common::register_user(&h.app).await;

    for title in ["Alpha", "Bravo", "Charlie"] {
        seed(&h.app, &cookie, title).await;
    }
    set_created_at(&pool, "Alpha", "2020-01-01T00:00:00Z").await;
    set_created_at(&pool, "Bravo", "2023-01-01T00:00:00Z").await;
    set_created_at(&pool, "Charlie", "2021-01-01T00:00:00Z").await;

    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?sort=recent"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    // Same ordering as `sort=created&order=desc`: newest-created first.
    assert_eq!(title_list(&body), vec!["Bravo", "Charlie", "Alpha"]);
}

#[sqlx::test(migrations = "./migrations")]
async fn list_sort_field_defaults_direction_per_field(pool: PgPool) {
    // When `order` is omitted, the API applies the field's natural direction:
    // text fields ascend (A→Z), matching the frontend's `defaultOrderFor`, so
    // a bare `?sort=title` link reads the same in the UI and over the wire.
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;

    for title in ["Vinland Saga", "Berserk", "One Piece"] {
        seed(&h.app, &cookie, title).await;
    }

    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?sort=title"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(title_list(&body), vec!["Berserk", "One Piece", "Vinland Saga"]);
}

#[sqlx::test(migrations = "./migrations")]
async fn list_sort_author_default_is_ascending_nulls_last(pool: PgPool) {
    // The other text field: a bare `?sort=author` must default to ascending and
    // keep authorless mangas last (NULLS LAST), exercising the omitted-order
    // path through the author subquery — not just `title`.
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;

    for (title, authors) in [
        ("Akira", json!(["Beta Author"])),
        ("Boku", json!(["Alpha Author"])),
        ("NoAuthor", json!([])),
    ] {
        let _ = h
            .app
            .clone()
            .oneshot(common::post_multipart_with_cookie(
                "/api/v1/mangas",
                MultipartBuilder::new()
                    .add_json("metadata", json!({ "title": title, "authors": authors })),
                &cookie,
            ))
            .await
            .unwrap();
    }

    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?sort=author"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    // Alpha Author (Boku) < Beta Author (Akira); authorless sorts last.
    assert_eq!(title_list(&body), vec!["Boku", "Akira", "NoAuthor"]);
}

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

    // Unknown sort field → structured 422, not axum's plain-text 400.
    let resp = h
        .app
        .clone()
        .oneshot(common::get("/api/v1/mangas?sort=bogus"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["details"]["sort"], "invalid");

    // Unknown order → same treatment.
    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?order=sideways"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["details"]["order"], "invalid");
}

/// Pin a manga's primary key to an explicit UUID. Safe only for freshly
/// seeded mangas with no chapters/authors/tags referencing them.
async fn set_id(pool: &PgPool, title: &str, id: &str) {
    let uuid: uuid::Uuid = id.parse().unwrap();
    sqlx::query("UPDATE mangas SET id = $1 WHERE title = $2")
        .bind(uuid)
        .bind(title)
        .execute(pool)
        .await
        .unwrap();
}

#[sqlx::test(migrations = "./migrations")]
async fn list_tie_break_orders_equal_keys_by_ascending_id(pool: PgPool) {
    // The trailing `, id` in ORDER BY is what keeps pagination stable when the
    // primary sort key ties. Pin three mangas to the SAME created_at and assign
    // ids whose ascending order (Bravo < Charlie < Alpha) deliberately differs
    // from insertion/physical order, so the expected ordering can ONLY come
    // from the id tie-break.
    //
    // We sort by `created` on purpose: `mangas_created_at_idx` is `(created_at
    // DESC)` with no id, so it does NOT encode the secondary order — unlike the
    // default `updated` path, whose `mangas_updated_at_idx (updated_at DESC,
    // id)` would mask a missing clause. Drop `, id` from the query and this
    // test fails.
    let h = common::harness(pool.clone());
    let (_, cookie) = common::register_user(&h.app).await;

    for title in ["Alpha", "Bravo", "Charlie"] {
        seed(&h.app, &cookie, title).await;
        set_created_at(&pool, title, "2022-01-01T00:00:00Z").await;
    }
    set_id(&pool, "Alpha", "33333333-3333-4333-8333-333333333333").await;
    set_id(&pool, "Bravo", "11111111-1111-4111-8111-111111111111").await;
    set_id(&pool, "Charlie", "22222222-2222-4222-8222-222222222222").await;

    let page = |limit: i64, offset: i64| {
        let app = h.app.clone();
        async move {
            let resp = app
                .oneshot(common::get(&format!(
                    "/api/v1/mangas?sort=created&limit={limit}&offset={offset}"
                )))
                .await
                .unwrap();
            common::body_json(resp).await
        }
    };

    // Single page: tied rows come back in ascending-id order.
    let full = page(50, 0).await;
    assert_eq!(title_list(&full), vec!["Bravo", "Charlie", "Alpha"]);

    // Paginating one row at a time reproduces that exact sequence — no overlap,
    // no gap — which only holds because the id tie-break makes the order total.
    let paged: Vec<String> = [page(1, 0).await, page(1, 1).await, page(1, 2).await]
        .iter()
        .map(|b| b["items"][0]["title"].as_str().unwrap().to_string())
        .collect();
    assert_eq!(paged, vec!["Bravo", "Charlie", "Alpha"]);
}

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

    for title in ["One Piece", "Berserk", "Vinland Saga"] {
        let _ = h
            .app
            .clone()
            .oneshot(common::post_multipart_with_cookie(
                "/api/v1/mangas",
                MultipartBuilder::new().add_json("metadata", json!({ "title": title })),
                &cookie,
            ))
            .await
            .unwrap();
    }

    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?search=berserk"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["items"].as_array().unwrap().len(), 1);
    assert_eq!(body["page"]["total"], 1);
}

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

    let created = h
        .app
        .clone()
        .oneshot(common::post_multipart_with_cookie(
            "/api/v1/mangas",
            MultipartBuilder::new().add_json(
                "metadata",
                json!({
                    "title": "Berserk",
                    "authors": ["Kentaro Miura"],
                    "description": null,
                }),
            ),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(created.status(), StatusCode::CREATED);
    let body = common::body_json(created).await;
    assert_eq!(body["title"], "Berserk");
    let authors = body["authors"].as_array().unwrap();
    assert_eq!(authors.len(), 1);
    assert_eq!(authors[0]["name"], "Kentaro Miura");
    assert!(body["id"].as_str().is_some());
    // Status defaults to ongoing; tags and genres start empty.
    assert_eq!(body["status"], "ongoing");
    assert_eq!(body["genres"], json!([]));
    assert_eq!(body["tags"], json!([]));

    let listed = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap();
    let listed_body = common::body_json(listed).await;
    let items = listed_body["items"].as_array().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0]["title"], "Berserk");
    // List endpoint returns the card shape: authors + genres included.
    let authors = items[0]["authors"].as_array().unwrap();
    assert_eq!(authors[0]["name"], "Kentaro Miura");
}

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

    for (title, author) in [
        ("One Piece", "Eiichiro Oda"),
        ("Berserk", "Kentaro Miura"),
        ("Vinland Saga", "Makoto Yukimura"),
    ] {
        let _ = h
            .app
            .clone()
            .oneshot(common::post_multipart_with_cookie(
                "/api/v1/mangas",
                MultipartBuilder::new()
                    .add_json("metadata", json!({ "title": title, "authors": [author] })),
                &cookie,
            ))
            .await
            .unwrap();
    }

    // Searching by author name still works — the list query joins
    // authors so 'miura' resolves through the manga_authors table.
    let resp = h
        .app
        .clone()
        .oneshot(common::get("/api/v1/mangas?search=miura"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let titles: Vec<&str> = body["items"]
        .as_array()
        .unwrap()
        .iter()
        .map(|m| m["title"].as_str().unwrap())
        .collect();
    assert_eq!(titles, vec!["Berserk"]);

    let resp = h
        .app
        .oneshot(common::get("/api/v1/mangas?search=saga"))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let titles: Vec<&str> = body["items"]
        .as_array()
        .unwrap()
        .iter()
        .map(|m| m["title"].as_str().unwrap())
        .collect();
    assert_eq!(titles, vec!["Vinland Saga"]);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_empty_title_with_validation_failed(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::post_multipart_with_cookie(
            "/api/v1/mangas",
            MultipartBuilder::new().add_json("metadata", metadata("   ")),
            &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"]["title"].is_string());
}

#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_missing_metadata_part(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::post_multipart_with_cookie(
            "/api/v1/mangas",
            MultipartBuilder::new(), // no metadata part
            &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_eq!(body["error"]["details"]["metadata"], "required");
}

#[sqlx::test(migrations = "./migrations")]
async fn create_requires_authentication(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h
        .app
        .oneshot(common::post_multipart(
            "/api/v1/mangas",
            MultipartBuilder::new().add_json("metadata", metadata("Berserk")),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "unauthenticated");
}

#[sqlx::test(migrations = "./migrations")]
async fn get_unknown_id_is_404_with_envelope(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h
        .app
        .oneshot(common::get(
            "/api/v1/mangas/00000000-0000-0000-0000-000000000000",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "not_found");
    let msg = body["error"]["message"].as_str().expect("message is string");
    assert!(!msg.is_empty(), "message should be non-empty");
}

// ----------------------------------------------------------------------------
// GET /v1/mangas/:id/similar — recommendation by tag overlap (Jaccard).
// ----------------------------------------------------------------------------

/// Attach a tag to a manga via the public API. Asserts the call succeeds
/// (201 created or 200 already-attached) so test setup failures surface loudly.
async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: uuid::Uuid, name: &str) {
    let resp = app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/tags"),
            json!({ "name": name }),
            cookie,
        ))
        .await
        .unwrap();
    assert!(
        resp.status() == StatusCode::CREATED || resp.status() == StatusCode::OK,
        "attach_tag({name}) failed: {}",
        resp.status()
    );
}

fn ids(body: &serde_json::Value) -> Vec<String> {
    body["items"]
        .as_array()
        .unwrap()
        .iter()
        .map(|m| m["id"].as_str().unwrap().to_string())
        .collect()
}

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

    let a = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
    let b = common::seed_manga_via_api(&h.app, &cookie, "Perfect Match").await;
    let c = common::seed_manga_via_api(&h.app, &cookie, "Tight Subset").await;
    let d = common::seed_manga_via_api(&h.app, &cookie, "Broad Overlap").await;
    let e = common::seed_manga_via_api(&h.app, &cookie, "No Overlap").await;

    // Source A: t1..t4
    for t in ["t1", "t2", "t3", "t4"] {
        attach_tag(&h.app, &cookie, a, t).await;
    }
    // B shares all 4 -> Jaccard 4/4 = 1.0
    for t in ["t1", "t2", "t3", "t4"] {
        attach_tag(&h.app, &cookie, b, t).await;
    }
    // C has t1,t2 -> shares 2 -> Jaccard 2/4 = 0.5
    for t in ["t1", "t2"] {
        attach_tag(&h.app, &cookie, c, t).await;
    }
    // D has t1..t8 -> shares 4 -> Jaccard 4/8 = 0.5 (tie with C; loses on shared-count tie-break)
    for t in ["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"] {
        attach_tag(&h.app, &cookie, d, t).await;
    }
    // E disjoint -> excluded
    for t in ["z1", "z2"] {
        attach_tag(&h.app, &cookie, e, t).await;
    }

    let resp = h
        .app
        .oneshot(common::get(&format!("/api/v1/mangas/{a}/similar")))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    let got = ids(&body);

    let b = b.to_string();
    let c = c.to_string();
    let d = d.to_string();
    // B (1.0) first; then C and D both 0.5 but C wins the shared-count tie-break (D shares 4 too,
    // wait — D shares 4, C shares 2). Tie-break is more-shared-first, so D precedes C.
    assert_eq!(got, vec![b, d, c], "ranked B(1.0), D(0.5,4 shared), C(0.5,2 shared)");
    // Self and the disjoint manga never appear.
    assert!(!got.contains(&a.to_string()));
    assert!(!got.contains(&e.to_string()));
    // Cards carry the enriched shape.
    assert!(body["items"][0]["authors"].is_array());
    assert!(body["items"][0]["genres"].is_array());
}

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

    let source = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
    attach_tag(&h.app, &cookie, source, "shared").await;

    for i in 0..7 {
        let m = common::seed_manga_via_api(&h.app, &cookie, &format!("Neighbor {i}")).await;
        attach_tag(&h.app, &cookie, m, "shared").await;
    }

    let resp = h
        .app
        .oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    let got = ids(&body);
    assert_eq!(got.len(), 5, "capped at 5");
    assert!(!got.contains(&source.to_string()), "self excluded");
}

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

    let resp = h
        .app
        .oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["items"], json!([]));
}

#[sqlx::test(migrations = "./migrations")]
async fn similar_empty_when_no_overlap(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let source = common::seed_manga_via_api(&h.app, &cookie, "Source").await;
    let other = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await;
    attach_tag(&h.app, &cookie, source, "alpha").await;
    attach_tag(&h.app, &cookie, other, "beta").await;

    let resp = h
        .app
        .oneshot(common::get(&format!("/api/v1/mangas/{source}/similar")))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["items"], json!([]));
}

#[sqlx::test(migrations = "./migrations")]
async fn similar_404_for_unknown_manga(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h
        .app
        .oneshot(common::get(&format!(
            "/api/v1/mangas/{}/similar",
            uuid::Uuid::new_v4()
        )))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "not_found");
}
