//! Cached sum of all media bytes the event is holding.
//!
//! The upload gate needs to know "how big would the keepsake be if we accept this file", because
//! the archive needs room for BOTH halves at once (`export::required_free_bytes` is
//! `media × 1.1 × 2` — the ZIP and the HTML viewer are each gallery-sized). Asking that question
//! per upload has to be cheap, and it has to be cheap on the busiest write path in the app.
//!
//! `export::estimate_export_bytes` answers the same question exactly, but it aggregates
//! `original_size_bytes` across every upload row joined to `user` — fine once per release,
//! wasteful per upload and growing all evening. This sums `user.total_upload_bytes` instead:
//! one row per guest (~100), already maintained transactionally by the quota path, already
//! refunded on delete.
//!
//! The two differ slightly — this one counts uploads belonging to banned or hidden users, which
//! the export filters out. That skew is in the SAFE direction: it over-estimates the archive, so
//! the gate closes marginally early rather than marginally late. Never swap it for a cheaper
//! query that could under-estimate; an under-estimate authorises the very upload that makes the
//! keepsake unbuildable, which is the failure this exists to prevent.

use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};

use sqlx::PgPool;

/// How long a reading is trusted. Shorter than [`crate::services::disk`]'s TTL because this
/// number only ever grows and does so in the same request path that reads it — a stale value
/// under-counts the newest uploads, and under-counting is the direction that matters.
const TTL: Duration = Duration::from_secs(5);

/// Cheap-to-clone cache of the event's total media bytes. Lives in `AppState`.
#[derive(Clone)]
pub struct MediaTotalCache {
    inner: Arc<RwLock<Option<(i64, Instant)>>>,
}

impl MediaTotalCache {
    pub fn new() -> Self {
        Self {
            inner: Arc::new(RwLock::new(None)),
        }
    }

    /// Drop the cached reading so the next `get()` re-queries.
    ///
    /// Used by the e2e TRUNCATE endpoint for the same reason `DiskCache::invalidate` exists:
    /// truncation removes every upload, and a surviving reading would make the next test's
    /// gate compute against the previous test's data.
    pub fn invalidate(&self) {
        *self.inner.write().unwrap() = None;
    }

    /// Total bytes of media the event is holding, cached for [`TTL`].
    ///
    /// Returns 0 when the query fails. That is a deliberate FAIL-OPEN, consistent with the
    /// quota path and the export preflight: a database blip must not turn into "every upload
    /// refused". The disk-space half of the gate still applies, so a failure here degrades the
    /// check to the old flat-reserve behaviour rather than disabling it.
    pub async fn get(&self, pool: &PgPool, event_slug: &str) -> i64 {
        if let Some((bytes, at)) = *self.inner.read().unwrap()
            && at.elapsed() < TTL
        {
            return bytes;
        }
        // Scoped to THIS event (H12). The unscoped `SUM(total_upload_bytes) FROM "user"` summed
        // every user row in the table, so reusing the install for a second event carried the first
        // one's bytes into the second one's keepsake-headroom gate — closing uploads early with a
        // message about "the event's storage" being full, counting media that belongs to a party
        // that already happened (and whose files are never reclaimed either).
        let queried = sqlx::query_scalar::<_, Option<i64>>(
            "SELECT SUM(u.total_upload_bytes)::bigint FROM \"user\" u
               JOIN event e ON e.id = u.event_id
              WHERE e.slug = $1",
        )
        .bind(event_slug)
        .fetch_one(pool)
        .await;

        let bytes = match queried {
            Ok(v) => v.unwrap_or(0).max(0),
            Err(e) => {
                // FAIL OPEN, but do NOT cache the failure, and do NOT let it pass silently.
                //
                // Storing 0 here pinned the gate's view of the event at "empty" for the whole
                // TTL. During that window `media_after` is just this upload, `keepsake_needs`
                // collapses to ~2.2x one file, and the gate degrades to the flat 10 GB reserve —
                // precisely the behaviour the two-halves design replaced, reappearing with no
                // trace in the log. And the trigger correlates with the danger: with
                // `max_connections = 10` and a 5s acquire timeout, this query fails exactly when
                // a burst is in progress.
                //
                // Falling back to the LAST GOOD reading (however stale) is strictly better than
                // 0: the total only ever grows, so a stale value under-counts slightly, while 0
                // under-counts by everything.
                let previous = self.inner.read().unwrap().map(|(b, _)| b);
                tracing::warn!(
                    error = %e,
                    fallback_bytes = previous.unwrap_or(0),
                    "media total query failed; upload gate is running on a stale reading"
                );
                return previous.unwrap_or(0);
            }
        };
        *self.inner.write().unwrap() = Some((bytes, Instant::now()));
        bytes
    }
}

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