//! Admission control for upload bodies, budgeted in BYTES rather than requests.
//!
//! ## Why this has to exist
//!
//! The keepsake headroom gate in `handlers::upload` cannot bound a burst, and the reason is
//! structural rather than a bug in the gate: the request body is streamed to a temp file during
//! multipart parsing, so the bytes are already on disk by the time any check runs. The gate can
//! only refuse to COMMIT them. Nothing upstream limited how many bodies stream at once — axum has
//! no such limit, the tower stack is just `TraceLayer`, and Caddy passes requests straight
//! through.
//!
//! So the failure mode is the ordinary one, not an attack: the ceremony ends, ~100 guests tap
//! "upload all", and ~100 bodies stream concurrently. At phone-video sizes that is 10-20 GB of
//! `.tmp` files on a 40 GB volume, none of it visible to the gate, and `DISK_RESERVE_BYTES` — the
//! 10 GB standing between the party and Postgres losing the volume it writes WAL to — is consumed
//! by transient files. The `.tmp` sweeper only reclaims files idle for an hour, correctly, which
//! means nothing reclaims a burst on this timescale.
//!
//! ## Why bytes and not a request count
//!
//! A flat "N concurrent uploads" limit has to be sized for the worst case (a 500 MB video), which
//! makes it absurdly restrictive for the common case (a 3 MB photo). Budgeting bytes lets one
//! 500 MB video and two hundred photos coexist under the same ceiling, and it means the ceiling is
//! stated in the unit the disk actually cares about.
//!
//! The reservation is the streaming CAP, not the real size — the real size is unknowable until the
//! body has been read, which is far too late. Reserving the cap is deliberately pessimistic; that
//! pessimism is the safety margin.
//!
//! ## Why a permit and not a counter
//!
//! `OwnedSemaphorePermit` releases on drop. Every path out of the upload handler — success, error,
//! a client vanishing mid-body, a panic — therefore returns the reservation without any explicit
//! bookkeeping. A hand-rolled `AtomicI64` would need a decrement on each of those paths, and the
//! one that gets missed is the one that leaks the budget until restart.

use std::sync::Arc;
use std::time::Duration;

use tokio::sync::{OwnedSemaphorePermit, Semaphore};

/// Total transient upload bytes allowed on disk at once, in MiB.
///
/// Sized against `DISK_RESERVE_BYTES` (10 GB): the reserve must survive a full burst with room to
/// spare, since Postgres is writing WAL to the same filesystem throughout. 4 GiB leaves ~6 GB of
/// the reserve untouched at the worst moment.
///
/// It is NOT a throughput limit. On 2 vCPU the box cannot usefully ingest more than this at once
/// anyway — compression, ffmpeg, Postgres and TLS all contend for the same two cores — so the
/// budget mostly converts "everything is slow and the disk fills" into "a few uploads wait".
const BUDGET_MIB: u32 = 4096;

/// How long an upload waits for room before being told to come back.
///
/// Long enough to absorb the burst (a photo holds its reservation for well under a second), short
/// enough that a guest is not left staring at a spinner. On timeout the handler answers 503 with
/// `Retry-After`, which the client queue already treats as transient and retries with backoff.
const WAIT: Duration = Duration::from_secs(20);

#[derive(Clone)]
pub struct UploadAdmission {
    permits: Arc<Semaphore>,
}

impl UploadAdmission {
    pub fn new() -> Self {
        Self {
            permits: Arc::new(Semaphore::new(BUDGET_MIB as usize)),
        }
    }

    /// Reserve room for a body capped at `cap_bytes`. The returned permit must be held for as long
    /// as the temp file exists.
    ///
    /// `None` means the wait timed out and the caller should shed the request.
    ///
    /// A cap larger than the whole budget is clamped rather than refused. Otherwise an operator
    /// raising `max_video_size_mb` above the budget would make `acquire_many` unsatisfiable and
    /// every video upload would hang until timeout — a config change silently disabling video for
    /// the event. Clamped, such an upload simply gets the whole budget to itself, which is the
    /// honest interpretation of "one file may fill the machine".
    pub async fn reserve(&self, cap_bytes: usize) -> Option<OwnedSemaphorePermit> {
        let mib = cap_bytes.div_ceil(1024 * 1024).max(1);
        let want = u32::try_from(mib).unwrap_or(BUDGET_MIB).min(BUDGET_MIB);
        match tokio::time::timeout(WAIT, self.permits.clone().acquire_many_owned(want)).await {
            Ok(Ok(permit)) => Some(permit),
            // The semaphore is never closed, so `Err` here is unreachable in practice; treat it
            // the same as a timeout rather than panicking on the upload path.
            Ok(Err(_)) => None,
            Err(_) => {
                tracing::warn!(
                    requested_mib = want,
                    "upload admission timed out; shedding to keep transient temp files bounded"
                );
                None
            }
        }
    }
}

impl Default for UploadAdmission {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The budget must actually block once exhausted — otherwise this whole module is decoration.
    #[tokio::test]
    async fn a_full_budget_sheds_instead_of_admitting() {
        let admission = UploadAdmission::new();
        let whole = admission
            .reserve(BUDGET_MIB as usize * 1024 * 1024)
            .await
            .expect("first reservation takes the whole budget");

        // Nothing left: a second reservation must not be granted. Raced against a short timeout so
        // the test does not sit for the full WAIT.
        let blocked =
            tokio::time::timeout(Duration::from_millis(150), admission.reserve(1024 * 1024)).await;
        assert!(
            blocked.is_err(),
            "budget exhausted, yet a reservation was granted"
        );

        // ...and releasing the permit makes room again, so the budget is not a one-way latch.
        drop(whole);
        assert!(
            admission.reserve(1024 * 1024).await.is_some(),
            "budget did not recover after the permit was dropped"
        );
    }

    /// A cap above the whole budget must be clamped, not left unsatisfiable. Unclamped,
    /// `acquire_many` for more permits than exist never completes, so raising
    /// `max_video_size_mb` past the budget would silently hang every video upload for 20s and
    /// then shed it.
    #[tokio::test]
    async fn a_cap_larger_than_the_budget_is_clamped_rather_than_unsatisfiable() {
        let admission = UploadAdmission::new();
        let oversized = (BUDGET_MIB as usize + 4096) * 1024 * 1024;
        assert!(
            admission.reserve(oversized).await.is_some(),
            "an over-budget cap must still be admittable on an idle server"
        );
    }
}
