mod common;

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

async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: 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().is_success(), "attach_tag: {}", resp.status());
}

async fn set_reaction(app: &axum::Router, cookie: &str, manga_id: Uuid, reaction: &str) {
    let resp = app
        .clone()
        .oneshot(common::put_json_with_cookie(
            &format!("/api/v1/mangas/{manga_id}/reaction"),
            json!({ "reaction": reaction }),
            cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

async fn bookmark(app: &axum::Router, cookie: &str, manga_id: Uuid) {
    let resp = 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);
}

async fn mark_read(app: &axum::Router, cookie: &str, manga_id: Uuid) {
    let resp = app
        .clone()
        .oneshot(common::put_json_with_cookie(
            "/api/v1/me/read-progress",
            json!({ "manga_id": manga_id.to_string(), "page": 1 }),
            cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

/// Recommended manga titles, in ranked order.
async fn recommend(app: &axum::Router, cookie: &str) -> Vec<String> {
    let resp = app
        .clone()
        .oneshot(common::get_with_cookie("/api/v1/me/recommendations", cookie))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    body["items"]
        .as_array()
        .unwrap()
        .iter()
        .map(|m| m["title"].as_str().unwrap().to_string())
        .collect()
}

#[sqlx::test(migrations = "./migrations")]
async fn likes_drive_recommendations(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
    let similar = common::seed_manga_via_api(&h.app, &cookie, "Similar").await;
    let unrelated = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await;
    attach_tag(&h.app, &cookie, liked, "action").await;
    attach_tag(&h.app, &cookie, similar, "action").await;
    attach_tag(&h.app, &cookie, unrelated, "sports").await;

    set_reaction(&h.app, &cookie, liked, "like").await;

    let recs = recommend(&h.app, &cookie).await;
    assert!(recs.contains(&"Similar".to_string()), "recs: {recs:?}");
    assert!(!recs.contains(&"Unrelated".to_string()), "recs: {recs:?}");
    // The liked manga itself is not recommended back.
    assert!(!recs.contains(&"Liked".to_string()), "recs: {recs:?}");
}

#[sqlx::test(migrations = "./migrations")]
async fn dislike_downranks_shared_tags(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
    let disliked = common::seed_manga_via_api(&h.app, &cookie, "Disliked").await;
    let good = common::seed_manga_via_api(&h.app, &cookie, "Good").await;
    let bad = common::seed_manga_via_api(&h.app, &cookie, "Bad").await;
    attach_tag(&h.app, &cookie, liked, "action").await;
    attach_tag(&h.app, &cookie, good, "action").await; // shares the liked tag
    attach_tag(&h.app, &cookie, disliked, "gore").await;
    attach_tag(&h.app, &cookie, bad, "gore").await; // shares the disliked tag

    set_reaction(&h.app, &cookie, liked, "like").await;
    set_reaction(&h.app, &cookie, disliked, "dislike").await;

    let recs = recommend(&h.app, &cookie).await;
    assert!(recs.contains(&"Good".to_string()), "recs: {recs:?}");
    // Net-negative (disliked-tag) candidate is dropped.
    assert!(!recs.contains(&"Bad".to_string()), "recs: {recs:?}");
}

#[sqlx::test(migrations = "./migrations")]
async fn bookmark_counts_as_half_a_like(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
    let booked = common::seed_manga_via_api(&h.app, &cookie, "Booked").await;
    let from_like = common::seed_manga_via_api(&h.app, &cookie, "FromLike").await;
    let from_bookmark = common::seed_manga_via_api(&h.app, &cookie, "FromBookmark").await;
    attach_tag(&h.app, &cookie, liked, "tliked").await;
    attach_tag(&h.app, &cookie, from_like, "tliked").await; // affinity 1.0
    attach_tag(&h.app, &cookie, booked, "tbooked").await;
    attach_tag(&h.app, &cookie, from_bookmark, "tbooked").await; // affinity 0.5

    set_reaction(&h.app, &cookie, liked, "like").await;
    bookmark(&h.app, &cookie, booked).await;

    let recs = recommend(&h.app, &cookie).await;
    // Both recommended, but the like-derived one outranks the bookmark-derived.
    let i_like = recs.iter().position(|t| t == "FromLike");
    let i_book = recs.iter().position(|t| t == "FromBookmark");
    assert!(i_like.is_some() && i_book.is_some(), "recs: {recs:?}");
    assert!(i_like < i_book, "like should outrank bookmark; recs: {recs:?}");
}

#[sqlx::test(migrations = "./migrations")]
async fn excludes_already_seen(pool: PgPool) {
    let h = common::harness(pool);
    let (_, cookie) = common::register_user(&h.app).await;
    let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
    let fresh = common::seed_manga_via_api(&h.app, &cookie, "Fresh").await;
    let already_read = common::seed_manga_via_api(&h.app, &cookie, "AlreadyRead").await;
    for m in [liked, fresh, already_read] {
        attach_tag(&h.app, &cookie, m, "action").await;
    }
    set_reaction(&h.app, &cookie, liked, "like").await;
    mark_read(&h.app, &cookie, already_read).await;

    let recs = recommend(&h.app, &cookie).await;
    assert!(recs.contains(&"Fresh".to_string()), "recs: {recs:?}");
    assert!(!recs.contains(&"AlreadyRead".to_string()), "recs: {recs:?}");
}

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

    assert!(recommend(&h.app, &cookie).await.is_empty());
}

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