//! Integration tests for `repo::page_analysis` — the transactional writer
//! that persists AI page-analysis output (OCR text, global auto-tags,
//! content warnings, the weighted search tsvector) and the `analyze_page`
//! job enqueue. Each `#[sqlx::test]` gets a fresh migrated DB.

use mangalord::domain::page_analysis::{
    AnalysisStatus, OcrResult, SafetyFlag, VisionAnalysis,
};
use mangalord::repo::page_analysis;
use sqlx::PgPool;
use uuid::Uuid;

/// Seed a manga → chapter → page chain and return the page id.
async fn seed_page(pool: &PgPool) -> Uuid {
    let manga_id: Uuid =
        sqlx::query_scalar("INSERT INTO mangas (title) VALUES ('M') RETURNING id")
            .fetch_one(pool)
            .await
            .unwrap();
    let chapter_id: Uuid = sqlx::query_scalar(
        "INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
    )
    .bind(manga_id)
    .fetch_one(pool)
    .await
    .unwrap();
    sqlx::query_scalar(
        "INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
         VALUES ($1, 1, 'mangas/x/p1.png', 'image/png') RETURNING id",
    )
    .bind(chapter_id)
    .fetch_one(pool)
    .await
    .unwrap()
}

fn analysis(
    ocr: &[(&str, &str)],
    tags: &[&str],
    scene: &str,
    nsfw: bool,
    warnings: &[&str],
) -> VisionAnalysis {
    VisionAnalysis {
        ocr_results: ocr
            .iter()
            .map(|(text, kind)| OcrResult {
                text: (*text).to_string(),
                kind: (*kind).to_string(),
                y: None,
            })
            .collect(),
        tagging_results: tags.iter().map(|t| t.to_string()).collect(),
        scene_description: scene.to_string(),
        safety_flag: SafetyFlag {
            is_nsfw: nsfw,
            content_type: warnings.iter().map(|w| w.to_string()).collect(),
        },
    }
}

async fn count(pool: &PgPool, table: &str, page_id: Uuid) -> i64 {
    sqlx::query_scalar(&format!("SELECT count(*) FROM {table} WHERE page_id = $1"))
        .bind(page_id)
        .fetch_one(pool)
        .await
        .unwrap()
}

#[sqlx::test(migrations = "./migrations")]
async fn persist_writes_ocr_tags_warnings_and_search_doc(pool: PgPool) {
    let page_id = seed_page(&pool).await;
    let a = analysis(
        &[("Hello", "speech"), ("BOOM", "sfx")],
        &["action", "city"],
        "A rainy street at night.",
        true,
        &["violence"],
    );

    page_analysis::persist_analysis(&pool, page_id, &a, "test-model")
        .await
        .unwrap();

    assert_eq!(count(&pool, "page_ocr_text", page_id).await, 2);
    assert_eq!(count(&pool, "page_auto_tags", page_id).await, 2);
    assert_eq!(count(&pool, "page_content_warnings", page_id).await, 1);

    let row = page_analysis::load(&pool, page_id).await.unwrap().unwrap();
    assert_eq!(row.status, AnalysisStatus::Done);
    assert!(row.is_nsfw);
    assert_eq!(row.scene_description.as_deref(), Some("A rainy street at night."));
    assert_eq!(row.model.as_deref(), Some("test-model"));

    // search_doc must be populated (non-null, non-empty).
    let has_doc: bool = sqlx::query_scalar(
        "SELECT search_doc IS NOT NULL AND search_doc != ''::tsvector \
           FROM page_analysis WHERE page_id = $1",
    )
    .bind(page_id)
    .fetch_one(&pool)
    .await
    .unwrap();
    assert!(has_doc, "search_doc should be a populated tsvector");
}

#[sqlx::test(migrations = "./migrations")]
async fn persist_is_idempotent_and_replaces_prior_result(pool: PgPool) {
    let page_id = seed_page(&pool).await;

    page_analysis::persist_analysis(
        &pool,
        page_id,
        &analysis(&[("one", "speech")], &["alpha", "beta"], "first", true, &["gore"]),
        "m1",
    )
    .await
    .unwrap();

    // Re-analysis with different output fully replaces the prior rows.
    page_analysis::persist_analysis(
        &pool,
        page_id,
        &analysis(&[("two", "narration")], &["beta", "gamma"], "second", false, &[]),
        "m2",
    )
    .await
    .unwrap();

    // Exactly one analysis row, reflecting the second pass.
    let total: i64 = sqlx::query_scalar("SELECT count(*) FROM page_analysis WHERE page_id = $1")
        .bind(page_id)
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(total, 1);

    let row = page_analysis::load(&pool, page_id).await.unwrap().unwrap();
    assert!(!row.is_nsfw);
    assert_eq!(row.scene_description.as_deref(), Some("second"));
    assert_eq!(row.model.as_deref(), Some("m2"));

    // Auto-tags are {beta, gamma}, NOT the union with the first pass.
    let tag_names: Vec<String> = sqlx::query_scalar(
        "SELECT t.name FROM page_auto_tags pat JOIN tags t ON t.id = pat.tag_id \
          WHERE pat.page_id = $1 ORDER BY t.name",
    )
    .bind(page_id)
    .fetch_all(&pool)
    .await
    .unwrap();
    assert_eq!(tag_names, vec!["beta", "gamma"]);

    // Warnings cleared (second pass had none).
    assert_eq!(count(&pool, "page_content_warnings", page_id).await, 0);
    assert_eq!(count(&pool, "page_ocr_text", page_id).await, 1);
}

#[sqlx::test(migrations = "./migrations")]
async fn persist_leaves_user_page_tags_untouched(pool: PgPool) {
    let page_id = seed_page(&pool).await;
    let user_id: Uuid = sqlx::query_scalar(
        "INSERT INTO users (username, password_hash) VALUES ('alice', 'x') RETURNING id",
    )
    .fetch_one(&pool)
    .await
    .unwrap();

    // A personal page tag the user added.
    mangalord::repo::page_tag::upsert(&pool, user_id, page_id, "favourite")
        .await
        .unwrap();

    // The auto-tagger proposes its own (overlapping + new) tags.
    page_analysis::persist_analysis(
        &pool,
        page_id,
        &analysis(&[], &["favourite", "robot"], "", false, &[]),
        "m",
    )
    .await
    .unwrap();

    // The user's personal tag survives untouched.
    let mine = mangalord::repo::page_tag::list_for_page(&pool, user_id, page_id)
        .await
        .unwrap();
    assert_eq!(mine, vec!["favourite"]);

    // And the global auto-tags are separate rows.
    assert_eq!(count(&pool, "page_auto_tags", page_id).await, 2);
}

#[sqlx::test(migrations = "./migrations")]
async fn enqueue_for_page_inserts_analyze_page_job(pool: PgPool) {
    let page_id = seed_page(&pool).await;

    page_analysis::enqueue_for_page(&pool, page_id, false)
        .await
        .unwrap();

    let payload: serde_json::Value = sqlx::query_scalar(
        "SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
    )
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(payload["page_id"].as_str().unwrap(), page_id.to_string());
    assert_eq!(payload["force"].as_bool(), Some(false));
}

#[sqlx::test(migrations = "./migrations")]
async fn mark_failed_writes_a_failed_row(pool: PgPool) {
    let page_id = seed_page(&pool).await;

    page_analysis::mark_failed(&pool, page_id, "model timed out")
        .await
        .unwrap();

    let row = page_analysis::load(&pool, page_id).await.unwrap().unwrap();
    assert_eq!(row.status, AnalysisStatus::Failed);
    assert_eq!(row.error.as_deref(), Some("model timed out"));
}

#[sqlx::test(migrations = "./migrations")]
async fn search_doc_ranks_speech_above_sfx(pool: PgPool) {
    let page_id = seed_page(&pool).await;
    // "alpha" is speech (weight A), "beta" is sfx (weight D).
    page_analysis::persist_analysis(
        &pool,
        page_id,
        &analysis(&[("alpha", "speech"), ("beta", "sfx")], &[], "", false, &[]),
        "m",
    )
    .await
    .unwrap();

    let (ra, rb): (f32, f32) = sqlx::query_as(
        "SELECT ts_rank(search_doc, plainto_tsquery('simple', 'alpha')), \
                ts_rank(search_doc, plainto_tsquery('simple', 'beta')) \
           FROM page_analysis WHERE page_id = $1",
    )
    .bind(page_id)
    .fetch_one(&pool)
    .await
    .unwrap();

    assert!(ra > 0.0 && rb > 0.0, "both terms should match the doc");
    assert!(
        ra > rb,
        "speech (weight A) must rank higher than sfx (weight D): {ra} vs {rb}"
    );
}
