//! DB-backed tests for the deleted-media sweep (`services/maintenance.rs`).
//!
//! Context, in two halves.
//!
//! The compression worker deliberately no longer deletes an upload's original when its transcode
//! fails — a transient ENOSPC or a codec panic must never destroy the only copy of a photo a guest
//! cannot retake. But the row is soft-deleted and the uploader's quota IS refunded, so those bytes
//! become invisible, unowned and free.
//!
//! The SAME hole was reachable by the ordinary path, and that one is not an edge case at all:
//! `soft_delete_in_event` refunds `total_upload_bytes` on every guest or host delete and nothing
//! removed the files, so the quota stopped bounding the disk. Upload 500 MB, delete, quota back to
//! zero, upload another 500 MB — a guest curating their camera roll, which is what people do. The
//! sweep used to reach only `compression_status = 'failed'`, so it never touched this case; the
//! test below that now asserts an owner-deleted upload IS reclaimed is the one that used to assert
//! the opposite.
//!
//! Two windows, because the two deletes mean different things: 14 days for a failure an operator
//! may want to investigate, 24 hours for a removal someone asked for (14 days outlives the whole
//! event, so a deliberate delete would never reclaim anything while it mattered).
//!
//! The selection predicate is the whole safety argument — it must reach both leftovers and never a
//! live upload — so that is what these pin, following the same "reproduce the SQL verbatim" pattern
//! as `upload_concurrency.rs`. `#[sqlx::test]` gives each test a fresh, migrated database.

mod common;

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

const FAILED_DAYS: i64 = 14;
const DELETED_HOURS: i64 = 24;

/// SRC: `services/maintenance.rs::cleanup_deleted_media` — the selection, verbatim.
async fn sweep_selects(pool: &PgPool, failed_days: i64, deleted_hours: i64) -> Vec<Uuid> {
    type Row = (Uuid, String, Option<String>, Option<String>, Option<String>);
    sqlx::query_as::<_, Row>(
        "SELECT id, original_path, preview_path, display_path, thumbnail_path FROM upload
         WHERE deleted_at IS NOT NULL
           AND CASE WHEN compression_status = 'failed'
                    THEN deleted_at < NOW() - ($1 || ' days')::interval
                    ELSE deleted_at < NOW() - ($2 || ' hours')::interval
               END
           AND (original_path <> '' OR preview_path IS NOT NULL
                OR display_path IS NOT NULL OR thumbnail_path IS NOT NULL)",
    )
    .bind(failed_days.to_string())
    .bind(deleted_hours.to_string())
    .fetch_all(pool)
    .await
    .expect("sweep query")
    .into_iter()
    .map(|(id, ..)| id)
    .collect()
}

/// Seed an upload aged `deleted_hours_ago` (None = live), with optional derivative paths.
async fn seed_aged_upload(
    pool: &PgPool,
    event_id: Uuid,
    user_id: Uuid,
    status: &str,
    deleted_hours_ago: Option<i64>,
    original_path: &str,
    derivatives: bool,
) -> Uuid {
    sqlx::query_scalar(
        "INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes,
                             compression_status, deleted_at,
                             preview_path, display_path, thumbnail_path)
         VALUES ($1, $2, $3, 'image/jpeg', 1000, $4,
                 CASE WHEN $5::bigint IS NULL THEN NULL
                      ELSE NOW() - ($5::text || ' hours')::interval END,
                 CASE WHEN $6 THEN 'previews/p.jpg' END,
                 CASE WHEN $6 THEN 'displays/d.jpg' END,
                 CASE WHEN $6 THEN 'thumbs/t.jpg' END)
         RETURNING id",
    )
    .bind(event_id)
    .bind(user_id)
    .bind(original_path)
    .bind(status)
    .bind(deleted_hours_ago)
    .bind(derivatives)
    .fetch_one(pool)
    .await
    .expect("seed upload")
}

/// A live upload is untouchable no matter how the windows are configured.
///
/// PREVENTS: the catastrophic loosening. Everything else here is about reclaiming more; this is the
/// one assertion that must never bend.
#[sqlx::test]
async fn a_live_upload_is_never_selected(pool: PgPool) {
    let event_id = seed_event(&pool, "sweep-live").await;
    let user_id = seed_user(&pool, event_id, "Sweeper").await;

    for status in ["done", "failed", "processing", "pending"] {
        let live = seed_aged_upload(
            &pool,
            event_id,
            user_id,
            status,
            None,
            "originals/e/live.jpg",
            true,
        )
        .await;
        assert!(
            !sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
                .await
                .contains(&live),
            "a non-deleted upload with status {status} must never be swept"
        );
    }
}

/// THE FIX. An upload a guest or host deliberately deleted is reclaimed once past 24 hours.
///
/// PREVENTS: the regression back to a sweep scoped to `compression_status = 'failed'`, which is
/// what let the quota stop bounding the disk. This assertion is the inverse of the one this file
/// used to make.
#[sqlx::test]
async fn a_deliberately_deleted_upload_is_reclaimed_after_a_day(pool: PgPool) {
    let event_id = seed_event(&pool, "sweep-deleted").await;
    let user_id = seed_user(&pool, event_id, "Curator").await;

    let deleted = seed_aged_upload(
        &pool,
        event_id,
        user_id,
        "done",
        Some(48),
        "originals/e/owner.jpg",
        true,
    )
    .await;
    // Still inside the window — a mis-tap is recoverable for a day.
    let recent = seed_aged_upload(
        &pool,
        event_id,
        user_id,
        "done",
        Some(2),
        "originals/e/recent.jpg",
        true,
    )
    .await;

    let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await;
    assert!(
        selected.contains(&deleted),
        "a deliberate delete past the window must be reclaimed — this is the leak"
    );
    assert!(
        !selected.contains(&recent),
        "a delete inside the window keeps its recovery grace"
    );
}

/// The two windows are independent: a failure is retained far longer than a deliberate delete.
///
/// PREVENTS: collapsing them into one. Applying 24h to failures would destroy the recovery window
/// the retained-original fix exists to provide; applying 14 days to deliberate deletes would mean
/// nothing is ever reclaimed during an event.
#[sqlx::test]
async fn the_two_retention_windows_do_not_bleed_into_each_other(pool: PgPool) {
    let event_id = seed_event(&pool, "sweep-windows").await;
    let user_id = seed_user(&pool, event_id, "Windows").await;

    // 48h old: past the deliberate window, nowhere near the failure window.
    let failed_recent = seed_aged_upload(
        &pool,
        event_id,
        user_id,
        "failed",
        Some(48),
        "originals/e/f-recent.jpg",
        false,
    )
    .await;
    let deleted_same_age = seed_aged_upload(
        &pool,
        event_id,
        user_id,
        "done",
        Some(48),
        "originals/e/d-same.jpg",
        false,
    )
    .await;
    // 30 days old: past both.
    let failed_old = seed_aged_upload(
        &pool,
        event_id,
        user_id,
        "failed",
        Some(30 * 24),
        "originals/e/f-old.jpg",
        false,
    )
    .await;

    let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await;
    assert!(
        !selected.contains(&failed_recent),
        "a 2-day-old compression failure is still inside its 14-day recovery window"
    );
    assert!(
        selected.contains(&deleted_same_age),
        "a deliberate delete of the same age is past its 24-hour window"
    );
    assert!(
        selected.contains(&failed_old),
        "a 30-day-old failure is past both windows"
    );
}

/// Boundary behaviour on both windows.
#[sqlx::test]
async fn retention_windows_are_honoured_at_the_boundary(pool: PgPool) {
    let event_id = seed_event(&pool, "sweep-boundary").await;
    let user_id = seed_user(&pool, event_id, "Boundary").await;

    let cases = [
        ("failed", 13 * 24, false, "13 days"),
        ("failed", 15 * 24, true, "15 days"),
        ("done", 23, false, "23 hours"),
        ("done", 25, true, "25 hours"),
    ];
    for (status, hours, expected, label) in cases {
        let id = seed_aged_upload(
            &pool,
            event_id,
            user_id,
            status,
            Some(hours),
            "originals/e/b.jpg",
            false,
        )
        .await;
        assert_eq!(
            sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
                .await
                .contains(&id),
            expected,
            "a {status} upload deleted {label} ago: expected swept={expected}"
        );
        sqlx::query("DELETE FROM upload WHERE id = $1")
            .bind(id)
            .execute(&pool)
            .await
            .expect("clean up");
    }
}

/// A row is re-selected until EVERY one of its paths is cleared.
///
/// PREVENTS: two failures at once. The sweep used to clear `original_path` alone, which was right
/// for its only case (a failed compression produces no derivatives) but leaves preview, display and
/// thumbnail on disk the moment it reaches a successfully processed upload — three files per
/// upload, none of them counted in `original_size_bytes`, that nothing else ever removes. And a row
/// whose paths are all cleared must stop coming back, or every hourly tick logs a phantom reclaim
/// forever.
#[sqlx::test]
async fn a_row_is_reselected_until_every_path_is_cleared(pool: PgPool) {
    let event_id = seed_event(&pool, "sweep-idempotent").await;
    let user_id = seed_user(&pool, event_id, "Idem").await;
    let id = seed_aged_upload(
        &pool,
        event_id,
        user_id,
        "done",
        Some(48),
        "originals/e/once.jpg",
        true,
    )
    .await;

    assert_eq!(sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await, [id]);

    // Clearing only the original is NOT enough — the derivatives are still on disk.
    sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1")
        .bind(id)
        .execute(&pool)
        .await
        .expect("clear original");
    assert_eq!(
        sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await,
        [id],
        "derivatives left behind must keep the row selected"
    );

    sqlx::query(
        "UPDATE upload SET preview_path = NULL, display_path = NULL, thumbnail_path = NULL
         WHERE id = $1",
    )
    .bind(id)
    .execute(&pool)
    .await
    .expect("clear derivatives");
    assert!(
        sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
            .await
            .is_empty(),
        "a fully swept row must not come back"
    );
}

/// The derivative backfill must never resurrect what the sweep just reclaimed.
///
/// PREVENTS: an interaction, not a bug in either piece. The sweep nulls `preview_path`, and
/// `backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT NULL` —
/// close enough that a future edit to either could have the backfill re-decode an original that is
/// no longer on disk, on every boot. `deleted_at IS NULL` is what keeps them apart.
#[sqlx::test]
async fn the_backfill_ignores_swept_rows(pool: PgPool) {
    let event_id = seed_event(&pool, "sweep-backfill").await;
    let user_id = seed_user(&pool, event_id, "Backfill").await;
    seed_aged_upload(
        &pool,
        event_id,
        user_id,
        "done",
        Some(48),
        "originals/e/gone.jpg",
        true,
    )
    .await;

    // SRC: `services/compression.rs::backfill_stale_derivatives` — the selection, verbatim.
    let backfilled: Vec<(Uuid, String, String)> = sqlx::query_as(
        "SELECT id, original_path, mime_type FROM upload
             WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
               AND original_path IS NOT NULL
               AND (
                     (display_path IS NULL AND preview_path IS NOT NULL)
                  OR derivatives_rev < $1
               )",
    )
    .bind(1i16)
    .fetch_all(&pool)
    .await
    .expect("backfill query");

    assert!(
        backfilled.is_empty(),
        "a soft-deleted row must be invisible to the backfill, before or after sweeping"
    );
}
