mod common;

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

async fn create_collection(
    app: &axum::Router,
    cookie: &str,
    name: &str,
) -> Value {
    let resp = app
        .clone()
        .oneshot(common::post_json_with_cookie(
            "/api/v1/collections",
            json!({ "name": name }),
            cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED, "create_collection failed");
    common::body_json(resp).await
}

fn id_of(v: &Value) -> String {
    v["id"].as_str().unwrap().to_string()
}

#[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 _favs = create_collection(&h.app, &cookie_a, "Favorites").await;
    let _read = create_collection(&h.app, &cookie_a, "Reading List").await;

    // User B sees an empty list.
    let resp = h
        .app
        .clone()
        .oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie_b))
        .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);

    // User A sees both.
    let resp = h
        .app
        .oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie_a))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let names: Vec<&str> = body["items"]
        .as_array()
        .unwrap()
        .iter()
        .map(|c| c["name"].as_str().unwrap())
        .collect();
    // Newest-updated first; both rows have the same updated_at on
    // create so we just sanity-check membership.
    assert_eq!(names.len(), 2);
    assert!(names.contains(&"Favorites"));
    assert!(names.contains(&"Reading List"));
    // Empty collections render with manga_count 0 and an empty
    // sample_covers array, not `null`.
    for item in body["items"].as_array().unwrap() {
        assert_eq!(item["manga_count"], 0);
        assert_eq!(item["sample_covers"], json!([]));
    }
}

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

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/collections",
            json!({ "name": "favorites" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CONFLICT);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "conflict");
}

#[sqlx::test(migrations = "./migrations")]
async fn two_users_can_share_a_collection_name(pool: PgPool) {
    let h = common::harness(pool);
    let (_, a) = common::register_user(&h.app).await;
    let (_, b) = common::register_user(&h.app).await;
    let _ = create_collection(&h.app, &a, "Favorites").await;
    // No conflict — uniqueness is per-(user_id, lower(name)).
    let _ = create_collection(&h.app, &b, "Favorites").await;
}

#[sqlx::test(migrations = "./migrations")]
async fn create_requires_authentication(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h
        .app
        .oneshot(common::post_json(
            "/api/v1/collections",
            json!({ "name": "Anon" }),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_blank_name_with_422(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(
            "/api/v1/collections",
            json!({ "name": "   " }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "./migrations")]
async fn get_one_returns_404_for_non_owner_no_existence_leak(pool: PgPool) {
    let h = common::harness(pool);
    let (_, a) = common::register_user(&h.app).await;
    let (_, b) = common::register_user(&h.app).await;
    let coll = create_collection(&h.app, &a, "Favorites").await;
    let id = id_of(&coll);

    // Owner-mismatch is collapsed to 404 so the API doesn't disclose
    // collection existence to non-owners. Otherwise an attacker could
    // distinguish "exists, not yours" from "doesn't exist" by status.
    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/collections/{id}"),
            &b,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn add_manga_is_idempotent_and_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, "Berserk").await;
    let coll = create_collection(&h.app, &cookie, "Favorites").await;
    let coll_id = id_of(&coll);

    let req = || {
        common::post_json_with_cookie(
            &format!("/api/v1/collections/{coll_id}/mangas"),
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        )
    };

    let first = h.app.clone().oneshot(req()).await.unwrap();
    assert_eq!(first.status(), StatusCode::CREATED);
    let second = h.app.oneshot(req()).await.unwrap();
    assert_eq!(second.status(), StatusCode::OK);
}

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

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/collections/{coll_id}/mangas"),
            json!({ "manga_id": Uuid::new_v4().to_string() }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn add_manga_to_someone_elses_collection_is_404(pool: PgPool) {
    let h = common::harness(pool);
    let (_, a) = common::register_user(&h.app).await;
    let (_, b) = common::register_user(&h.app).await;
    let coll_a = create_collection(&h.app, &a, "Mine").await;
    let coll_a_id = id_of(&coll_a);
    let manga_id = common::seed_manga_via_api(&h.app, &b, "Anything").await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/collections/{coll_a_id}/mangas"),
            json!({ "manga_id": manga_id.to_string() }),
            &b,
        ))
        .await
        .unwrap();
    // 404 not 403 — same non-existence-leak rationale as `get_one`.
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn patch_on_other_users_collection_is_404(pool: PgPool) {
    let h = common::harness(pool);
    let (_, a) = common::register_user(&h.app).await;
    let (_, b) = common::register_user(&h.app).await;
    let coll = create_collection(&h.app, &a, "Mine").await;
    let id = id_of(&coll);
    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/collections/{id}"),
            json!({ "name": "Hijacked" }),
            &b,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "./migrations")]
async fn patch_description_null_clears_existing_value(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let coll = create_collection(&h.app, &cookie, "C").await;
    let id = id_of(&coll);
    // Seed a description first via PATCH.
    let _ = h
        .app
        .clone()
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/collections/{id}"),
            json!({ "description": "starting desc" }),
            &cookie,
        ))
        .await
        .unwrap();
    // Now PATCH with description=null and expect the column cleared.
    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/collections/{id}"),
            json!({ "description": null }),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert!(body["description"].is_null(), "expected description cleared");
}

#[sqlx::test(migrations = "./migrations")]
async fn patch_description_empty_string_sets_empty_not_null(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let coll = create_collection(&h.app, &cookie, "C").await;
    let id = id_of(&coll);
    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/collections/{id}"),
            json!({ "description": "" }),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    // Empty string is a valid distinct value; only `null` clears.
    assert_eq!(body["description"], "");
}

#[sqlx::test(migrations = "./migrations")]
async fn patch_description_omitted_leaves_value_intact(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let coll = create_collection(&h.app, &cookie, "C").await;
    let id = id_of(&coll);
    let _ = h
        .app
        .clone()
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/collections/{id}"),
            json!({ "description": "Keep me" }),
            &cookie,
        ))
        .await
        .unwrap();
    // PATCH that doesn't mention description must not touch it.
    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/collections/{id}"),
            json!({ "name": "Renamed" }),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    assert_eq!(body["name"], "Renamed");
    assert_eq!(body["description"], "Keep me");
}

#[sqlx::test(migrations = "./migrations")]
async fn patch_with_empty_body_leaves_row_unchanged(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let coll = create_collection(&h.app, &cookie, "Stable").await;
    let id = id_of(&coll);
    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/collections/{id}"),
            json!({}),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["name"], "Stable");
}

#[sqlx::test(migrations = "./migrations")]
async fn my_collections_for_unknown_manga_returns_empty_list(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/mangas/{}/my-collections", Uuid::new_v4()),
            &cookie,
        ))
        .await
        .unwrap();
    // Non-existent manga is treated the same as a manga the user
    // hasn't collected — empty list. The handler comment documents
    // this; the test pins it.
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["collection_ids"], json!([]));
}

#[sqlx::test(migrations = "./migrations")]
async fn list_mangas_returns_collection_contents(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let m1 = common::seed_manga_via_api(&h.app, &cookie, "First").await;
    let m2 = common::seed_manga_via_api(&h.app, &cookie, "Second").await;
    let _untagged = common::seed_manga_via_api(&h.app, &cookie, "NotInIt").await;
    let coll = create_collection(&h.app, &cookie, "Mix").await;
    let coll_id = id_of(&coll);

    for m in [m1, m2] {
        let r = h
            .app
            .clone()
            .oneshot(common::post_json_with_cookie(
                &format!("/api/v1/collections/{coll_id}/mangas"),
                json!({ "manga_id": m.to_string() }),
                &cookie,
            ))
            .await
            .unwrap();
        assert_eq!(r.status(), StatusCode::CREATED);
    }

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/collections/{coll_id}/mangas"),
            &cookie,
        ))
        .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();
    // Newest-added first.
    assert_eq!(titles, vec!["Second", "First"]);
    assert_eq!(body["page"]["total"], 2);
}

#[sqlx::test(migrations = "./migrations")]
async fn remove_manga_is_idempotent(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 coll = create_collection(&h.app, &cookie, "C").await;
    let coll_id = id_of(&coll);

    let _ = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/collections/{coll_id}/mangas"),
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();

    let first = h
        .app
        .clone()
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/collections/{coll_id}/mangas/{manga_id}"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(first.status(), StatusCode::NO_CONTENT);
    // Removing again is still a 204 — DELETE is idempotent.
    let second = h
        .app
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/collections/{coll_id}/mangas/{manga_id}"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(second.status(), StatusCode::NO_CONTENT);
}

#[sqlx::test(migrations = "./migrations")]
async fn my_collections_for_manga_lists_only_owned_containing(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, "X").await;

    let a_coll = create_collection(&h.app, &a, "A's").await;
    let b_coll = create_collection(&h.app, &b, "B's").await;
    let a_coll_id = id_of(&a_coll);
    let b_coll_id = id_of(&b_coll);

    for (coll, cookie) in [(&a_coll_id, &a), (&b_coll_id, &b)] {
        let _ = h
            .app
            .clone()
            .oneshot(common::post_json_with_cookie(
                &format!("/api/v1/collections/{coll}/mangas"),
                json!({ "manga_id": manga_id.to_string() }),
                cookie,
            ))
            .await
            .unwrap();
    }

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/my-collections"),
            &a,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let ids: Vec<&str> = body["collection_ids"]
        .as_array()
        .unwrap()
        .iter()
        .map(|v| v.as_str().unwrap())
        .collect();
    assert_eq!(ids, vec![a_coll_id.as_str()]);
}

#[sqlx::test(migrations = "./migrations")]
async fn patch_collection_updates_name_and_description(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let coll = create_collection(&h.app, &cookie, "Old name").await;
    let id = id_of(&coll);

    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/collections/{id}"),
            json!({ "name": "New name", "description": "Some notes" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    assert_eq!(body["name"], "New name");
    assert_eq!(body["description"], "Some notes");
}

#[sqlx::test(migrations = "./migrations")]
async fn delete_collection_cascades_attachments(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, "M").await;
    let coll = create_collection(&h.app, &cookie, "C").await;
    let coll_id = id_of(&coll);

    let _ = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/collections/{coll_id}/mangas"),
            json!({ "manga_id": manga_id.to_string() }),
            &cookie,
        ))
        .await
        .unwrap();

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

    let (count,): (i64,) =
        sqlx::query_as("SELECT count(*) FROM collection_mangas WHERE collection_id = $1")
            .bind(Uuid::parse_str(&coll_id).unwrap())
            .fetch_one(&pool)
            .await
            .unwrap();
    assert_eq!(count, 0, "collection_mangas should cascade-delete with the collection");
}

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

    // Seed a manga with a cover via the upload endpoint so the
    // cover_image_path column gets populated.
    let make_metadata = |title: &str| {
        common::MultipartBuilder::new()
            .add_json("metadata", json!({ "title": title }))
            .add_file("cover", "cover.png", "image/png", &common::fake_png_bytes())
    };
    let resp = h
        .app
        .clone()
        .oneshot(common::post_multipart_with_cookie(
            "/api/v1/mangas",
            make_metadata("With cover"),
            &cookie,
        ))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let manga_id = body["id"].as_str().unwrap().to_string();

    let coll = create_collection(&h.app, &cookie, "Visual").await;
    let coll_id = id_of(&coll);
    let r = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/collections/{coll_id}/mangas"),
            json!({ "manga_id": manga_id }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(r.status(), StatusCode::CREATED);

    let resp = h
        .app
        .oneshot(common::get_with_cookie("/api/v1/me/collections", &cookie))
        .await
        .unwrap();
    let body = common::body_json(resp).await;
    let item = &body["items"][0];
    assert_eq!(item["manga_count"], 1);
    let covers = item["sample_covers"].as_array().unwrap();
    assert_eq!(covers.len(), 1);
    assert!(covers[0]
        .as_str()
        .unwrap()
        .starts_with(&format!("mangas/{manga_id}/cover")));
}
