//! DB-backed tests for the upload idempotency key (migration 022).
//!
//! The guarantee under test is the one thing standing between a lost response and a duplicated
//! wedding photo: a retry of an upload that already committed must NOT create a second row, and
//! must not charge the guest's storage quota twice. The whole mechanism is SQL — a partial unique
//! index plus `ON CONFLICT DO NOTHING` — so it is tested against a real database with the real
//! migrations applied, using the same statements `src/` runs.

mod common;

use common::*;
use sqlx::PgPool;
use uuid::Uuid;

/// SRC: `models/upload.rs::Upload::create` — the insert, verbatim.
///
/// Returns the new row's id, or `None` when the key was already stored. The handler treats
/// `None` as "a concurrent retry won" and replays the stored row instead of committing.
async fn create_upload(
    pool: &PgPool,
    event_id: Uuid,
    user_id: Uuid,
    original_path: &str,
    client_upload_id: Option<Uuid>,
) -> Option<Uuid> {
    let row: Option<(Uuid,)> = sqlx::query_as(
        "INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND (deleted_at IS NULL OR taken_down_by_host) DO NOTHING
         RETURNING id",
    )
    .bind(event_id)
    .bind(user_id)
    .bind(original_path)
    .bind("image/jpeg")
    .bind(1_000i64)
    .bind(Option::<String>::None)
    .bind(client_upload_id)
    .fetch_optional(pool)
    .await
    .expect("create_upload");
    row.map(|(id,)| id)
}

/// SRC: `models/upload.rs::Upload::find_by_client_upload_id` — the lookup, verbatim.
async fn find_by_key(pool: &PgPool, user_id: Uuid, client_upload_id: Uuid) -> Option<Uuid> {
    let row: Option<(Uuid,)> = sqlx::query_as(
        "SELECT id FROM upload
         WHERE client_upload_id = $1 AND user_id = $2 AND deleted_at IS NULL",
    )
    .bind(client_upload_id)
    .bind(user_id)
    .fetch_optional(pool)
    .await
    .expect("find_by_key");
    row.map(|(id,)| id)
}

async fn upload_count(pool: &PgPool) -> i64 {
    sqlx::query_scalar("SELECT COUNT(*) FROM upload")
        .fetch_one(pool)
        .await
        .expect("upload_count")
}

/// The core guarantee. A phone that loses the response and re-sends the same photo gets the
/// original row back, not a second copy in the gallery and a second charge against its quota.
#[sqlx::test]
async fn the_same_key_can_only_ever_store_one_upload(pool: PgPool) {
    let event_id = seed_event(&pool, "wedding").await;
    let user_id = seed_user(&pool, event_id, "Wackelige Wanda").await;
    let key = Uuid::new_v4();

    let first = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
    assert!(first.is_some(), "the first attempt must store the upload");

    // The retry: same key, and (as after a real re-send) a different file on disk.
    let second = create_upload(&pool, event_id, user_id, "originals/b.jpg", Some(key)).await;
    assert!(
        second.is_none(),
        "a retry of a committed upload must not insert a second row"
    );
    assert_eq!(upload_count(&pool).await, 1);

    // And the handler can find the winner to replay it.
    assert_eq!(find_by_key(&pool, user_id, key).await, first);
}

/// The index must not over-reach. Two genuinely different photos carry different keys and must
/// both land — this is the ordinary case, and breaking it would silently drop uploads.
#[sqlx::test]
async fn different_keys_are_different_uploads(pool: PgPool) {
    let event_id = seed_event(&pool, "wedding").await;
    let user_id = seed_user(&pool, event_id, "Fleißige Frieda").await;

    for _ in 0..5 {
        assert!(
            create_upload(
                &pool,
                event_id,
                user_id,
                "originals/x.jpg",
                Some(Uuid::new_v4())
            )
            .await
            .is_some()
        );
    }
    assert_eq!(upload_count(&pool).await, 5);
}

/// The index is PARTIAL, and this is why. Every upload that predates migration 022, and any
/// client that doesn't send a key, carries NULL — if those collided, the first such upload would
/// block every subsequent one and the whole event would fail after one photo.
#[sqlx::test]
async fn uploads_without_a_key_never_collide(pool: PgPool) {
    let event_id = seed_event(&pool, "wedding").await;
    let user_id = seed_user(&pool, event_id, "Alte Anna").await;

    for _ in 0..5 {
        assert!(
            create_upload(&pool, event_id, user_id, "originals/legacy.jpg", None)
                .await
                .is_some(),
            "a NULL key must never be treated as a duplicate"
        );
    }
    assert_eq!(upload_count(&pool).await, 5);
}

/// The lookup is scoped to the owner. The key alone is unique, so this can only matter if a key
/// ever repeated across users — but a replay that handed one guest another guest's upload row
/// would be a data leak, so the scope is asserted rather than assumed.
#[sqlx::test]
async fn the_replay_lookup_never_crosses_users(pool: PgPool) {
    let event_id = seed_event(&pool, "wedding").await;
    let owner = seed_user(&pool, event_id, "Besitzerin Bea").await;
    let other = seed_user(&pool, event_id, "Fremder Franz").await;
    let key = Uuid::new_v4();

    let id = create_upload(&pool, event_id, owner, "originals/a.jpg", Some(key)).await;

    assert_eq!(find_by_key(&pool, owner, key).await, id);
    assert_eq!(
        find_by_key(&pool, other, key).await,
        None,
        "another guest's retry must not resolve to this upload"
    );
}

/// A deleted photo must not be resurrected by a stale queue item. If the guest uploaded, deleted,
/// and their queue then retried the original request, replaying the deleted row would put the
/// photo they removed back in the gallery — so the lookup excludes soft-deleted rows and the
/// retry becomes a fresh upload instead.
#[sqlx::test]
async fn a_deleted_upload_is_not_replayed(pool: PgPool) {
    let event_id = seed_event(&pool, "wedding").await;
    let user_id = seed_user(&pool, event_id, "Reumütige Rita").await;
    let key = Uuid::new_v4();

    let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
        .await
        .expect("first insert");
    // `taken_down_by_host = FALSE` — the GUEST deleted their own photo. See migration 031 and the
    // sibling test below: the two cases must behave differently, and this is the one that frees
    // the key.
    sqlx::query("UPDATE upload SET deleted_at = NOW(), taken_down_by_host = FALSE WHERE id = $1")
        .bind(id)
        .execute(&pool)
        .await
        .expect("soft delete");

    assert_eq!(
        find_by_key(&pool, user_id, key).await,
        None,
        "a soft-deleted upload must not be replayed"
    );

    // The other half of that rule, and the half that was missing (H9). Asserting only that the
    // lookup returns None left the index free to disagree with it: migration 022's predicate
    // covered soft-deleted rows, so the retry's INSERT hit `ON CONFLICT DO NOTHING` against the
    // dead row, the replay lookup above then found nothing, and the handler answered 409 — which
    // the client classifies terminal and purges the blob for. The photo was gone from the phone
    // AND absent from the gallery, with no way back.
    //
    // Migration 026 narrowed the index to live rows so a retry after a delete inserts a FRESH
    // upload, which is what `find_by_client_upload_id`'s own doc comment always claimed happened.
    let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
    assert!(
        retried.is_some(),
        "a retry after the guest deleted the photo must create a fresh upload, not 409 forever"
    );
    assert_ne!(
        retried,
        Some(id),
        "the retry must be a new row, not the dead one"
    );
    assert_eq!(
        find_by_key(&pool, user_id, key).await,
        retried,
        "the live row is the one the replay lookup must now find"
    );
}

/// The mirror of the test above, and the case migration 026's rationale did not consider.
///
/// `deleted_at` is set by the guest deleting their own photo AND by `host_delete_upload`. Freeing
/// the idempotency key on both meant a takedown could be silently undone: the guest's response was
/// lost, so their queue still holds the item; the host removes the photo (bumping the keepsake
/// epoch and rebuilding the archive without it); the phone reconnects ten minutes later and
/// retries; the key is free, the INSERT succeeds, and the photo is back in the feed and in the next
/// keepsake — under a NEW uuid that matches nothing in the host's moderation history, with nothing
/// logged to say a takedown was reversed. The host has to find and delete it a second time.
///
/// Migration 031 keeps the key claimed for a host takedown, so the retry resolves to the duplicate
/// path and is refused. Refusing is the correct answer here: the photo was deliberately removed.
#[sqlx::test]
async fn a_host_takedown_is_not_undone_by_a_late_retry(pool: PgPool) {
    let event_id = seed_event(&pool, "wedding").await;
    let user_id = seed_user(&pool, event_id, "Übermütiger Uwe").await;
    let key = Uuid::new_v4();

    let id = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key))
        .await
        .expect("first insert");

    // SRC: `models/upload.rs::Upload::soft_delete_in_event` with `by_host = true`.
    sqlx::query("UPDATE upload SET deleted_at = NOW(), taken_down_by_host = TRUE WHERE id = $1")
        .bind(id)
        .execute(&pool)
        .await
        .expect("host takedown");

    let retried = create_upload(&pool, event_id, user_id, "originals/a.jpg", Some(key)).await;
    assert_eq!(
        retried, None,
        "a retry after a HOST takedown must be suppressed — otherwise the phone silently \
         reinstates a photo the hosts removed"
    );

    let live: i64 = sqlx::query_scalar(
        "SELECT count(*) FROM upload WHERE client_upload_id = $1 AND deleted_at IS NULL",
    )
    .bind(key)
    .fetch_one(&pool)
    .await
    .expect("count");
    assert_eq!(live, 0, "the taken-down photo must stay gone");

    // And the handler must be able to tell the guest WHY, rather than "already processed".
    // SRC: `models/upload.rs::Upload::taken_down_by_client_upload_id`.
    let was_taken_down: bool = sqlx::query_scalar(
        "SELECT EXISTS (
             SELECT 1 FROM upload
             WHERE client_upload_id = $1 AND user_id = $2
               AND deleted_at IS NOT NULL AND taken_down_by_host
         )",
    )
    .bind(key)
    .bind(user_id)
    .fetch_one(&pool)
    .await
    .expect("takedown lookup");
    assert!(
        was_taken_down,
        "the refusal must be attributable to a takedown so the queue can say so"
    );
}
