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 attach_creates_tag_and_links_to_manga(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(
            &format!("/api/v1/mangas/{manga_id}/tags"),
            json!({ "name": "Dark Fantasy" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let body = common::body_json(resp).await;
    assert_eq!(body["name"], "Dark Fantasy");
    assert!(body["id"].as_str().is_some());
    assert!(body["added_by"].as_str().is_some());

    // The tag is now visible on the manga detail response.
    let resp = h
        .app
        .oneshot(common::get(&format!("/api/v1/mangas/{manga_id}")))
        .await
        .unwrap();
    let detail = common::body_json(resp).await;
    let tags = detail["tags"].as_array().unwrap();
    assert_eq!(tags.len(), 1);
    assert_eq!(tags[0]["name"], "Dark Fantasy");
}

#[sqlx::test(migrations = "./migrations")]
async fn reattach_same_tag_is_idempotent_and_returns_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 make = || {
        common::post_json_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/tags"),
            json!({ "name": "Gritty" }),
            &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::OK);
}

/// Tag names over 64 chars are rejected at the handler boundary. The
/// repo enforces the same cap, but doing it at the handler keeps the
/// envelope consistent with the other validation paths
/// (username, collection name, etc.).
#[sqlx::test(migrations = "./migrations")]
async fn attach_rejects_tag_name_over_64_chars(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 long_name: String = "x".repeat(65);
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/tags"),
            json!({ "name": long_name }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "validation_failed");
}

#[sqlx::test(migrations = "./migrations")]
async fn tag_names_dedup_case_insensitively(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 first = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/tags"),
            json!({ "name": "Seinen" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(first.status(), StatusCode::CREATED);
    let first_body = common::body_json(first).await;
    let first_id = first_body["id"].as_str().unwrap().to_string();

    // Different casing on a second manga should resolve to the same tag id.
    let other_manga = common::seed_manga_via_api(&h.app, &cookie, "Other").await;
    let second = h
        .app
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/mangas/{other_manga}/tags"),
            json!({ "name": "seinen" }),
            &cookie,
        ))
        .await
        .unwrap();
    let second_body = common::body_json(second).await;
    assert_eq!(second_body["id"].as_str().unwrap(), first_id);
}

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

    let attach = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/tags"),
            json!({ "name": "Classic" }),
            &cookie_a,
        ))
        .await
        .unwrap();
    let attach_body = common::body_json(attach).await;
    let tag_id = attach_body["id"].as_str().unwrap();

    // User B cannot remove user A's tag attachment.
    let resp = h
        .app
        .clone()
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/tags/{tag_id}"),
            &cookie_b,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);

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

#[sqlx::test(migrations = "./migrations")]
async fn detach_404_when_not_attached(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 unknown_tag = Uuid::new_v4();
    let resp = h
        .app
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/tags/{unknown_tag}"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

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

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

#[sqlx::test(migrations = "./migrations")]
async fn attach_404_on_unknown_manga(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/mangas/{}/tags", Uuid::new_v4()),
            json!({ "name": "Anything" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

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

    let attach = h
        .app
        .clone()
        .oneshot(common::post_json_with_cookie(
            &format!("/api/v1/mangas/{tagged}/tags"),
            json!({ "name": "Recommendation" }),
            &cookie,
        ))
        .await
        .unwrap();
    let tag_id = common::body_json(attach).await["id"].as_str().unwrap().to_string();

    let resp = h
        .app
        .oneshot(common::get(&format!("/api/v1/mangas?tag_id={tag_id}")))
        .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!["Tagged"]);
}

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

    for name in ["Mystery", "Murder Mystery", "Comedy"] {
        let _ = h
            .app
            .clone()
            .oneshot(common::post_json_with_cookie(
                &format!("/api/v1/mangas/{manga_id}/tags"),
                json!({ "name": name }),
                &cookie,
            ))
            .await
            .unwrap();
    }

    let resp = h
        .app
        .oneshot(common::get("/api/v1/tags?search=myst"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    let names: Vec<&str> = body
        .as_array()
        .unwrap()
        .iter()
        .map(|t| t["name"].as_str().unwrap())
        .collect();
    assert!(names.contains(&"Mystery"));
    assert!(names.contains(&"Murder Mystery"));
    assert!(!names.contains(&"Comedy"));
}
