//! Cached view of the filesystem backing the media directory.
//!
//! Free/total disk space is needed on two hot paths — the per-user storage quota
//! (checked on every upload *and* every `GET /me/quota` poll) and the admin stats
//! endpoint. Reading it means `sysinfo::Disks::new_with_refreshed_list()`, which stats
//! every mounted filesystem; doing that per request is wasteful for a number that
//! barely moves. [`DiskCache`] refreshes it at most once per [`TTL`] and serves the
//! rest from memory.

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

/// How long a disk reading is trusted before the next call re-stats the filesystem.
const TTL: Duration = Duration::from_secs(15);

#[derive(Clone, Copy)]
pub struct DiskInfo {
    pub total: u64,
    pub free: u64,
}

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

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

    /// Drop the cached reading so the next `snapshot()` re-measures the filesystem.
    ///
    /// Used by the e2e TRUNCATE endpoint. Truncating deletes every uploaded file, which materially
    /// changes free space — but the cached reading survives for up to the TTL, so the next test can
    /// compute a quota from the PREVIOUS test's disk. That matters now that the quota tests steer
    /// the per-user limit off `free_disk_bytes`: a stale reading makes the limit wrong and the test
    /// flaky, for reasons that have nothing to do with the code under test.
    pub fn invalidate(&self) {
        *self.inner.write().unwrap() = None;
    }

    /// Cached `(total, free)` bytes for the filesystem that holds `media_path`.
    ///
    /// Returns `None` when the mount can't be resolved — callers MUST treat that as
    /// "unknown", never "zero free". (The quota path in particular fails *open* on
    /// `None`: enforcing a 0-byte limit would lock every user out of uploading.)
    /// Cached free-space reading for `path`.
    ///
    /// The cache is keyed BY PATH. It used to hold a single slot and ignore its argument on a hit,
    /// so it would happily return the media volume's numbers for any other path within the TTL.
    /// That was invisible only because every caller happened to pass `media_path` — the first
    /// caller to ask about a different volume (e.g. the exports volume, which is a separate mount)
    /// would have silently got the wrong filesystem's free space.
    pub fn snapshot(&self, path: &Path) -> Option<DiskInfo> {
        if let Some((cached_path, info, at)) = self.inner.read().unwrap().as_ref()
            && cached_path == path
            && at.elapsed() < TTL
        {
            return Some(*info);
        }
        let info = read_disk_for_path(path)?;
        *self.inner.write().unwrap() = Some((path.to_path_buf(), info, Instant::now()));
        Some(info)
    }
}

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

/// UNCACHED free-space reading for the filesystem backing `path`.
///
/// Deliberately bypasses [`DiskCache`]. The cache exists for the quota poll, where a 15s-stale
/// number is fine because it is only ever advisory. The export preflight is the opposite case: it
/// decides whether to start writing a multi-GB archive, and the sibling export worker running
/// concurrently can move free space by tens of gigabytes well inside the TTL. A stale reading there
/// would authorise exactly the write that fills the disk.
pub fn free_bytes(path: &Path) -> Option<u64> {
    read_disk_for_path(path).map(|d| d.free)
}

/// Resolve the filesystem backing `media_path` and read its total/free bytes.
///
/// Snapshots the mount table via `sysinfo`, then delegates the selection to the pure
/// [`select_disk`] so the (fiddly, edge-case-prone) matching logic is unit-testable
/// without touching the real filesystem.
fn read_disk_for_path(media_path: &Path) -> Option<DiskInfo> {
    let disks = sysinfo::Disks::new_with_refreshed_list();
    let mounts: Vec<(String, u64, u64)> = disks
        .iter()
        .map(|d| {
            (
                d.mount_point().to_string_lossy().to_string(),
                d.total_space(),
                d.available_space(),
            )
        })
        .collect();
    select_disk(&mounts, &media_path.to_string_lossy())
}

/// Pick the filesystem for `media_path` from a `(mount_point, total, free)` table.
///
/// Chooses the **longest** mount point that is a prefix of `media_path` (the most
/// specific filesystem) rather than the first match — otherwise the root `/` mount,
/// which prefixes every absolute path, could shadow a dedicated `/media` volume. Falls
/// back to `/` when nothing prefixes the path (e.g. a relative media path), and to
/// `None` when even that is absent — the caller treats `None` as "unknown" and fails
/// open on quota.
fn select_disk(mounts: &[(String, u64, u64)], media_path: &str) -> Option<DiskInfo> {
    mounts
        .iter()
        .filter(|(mp, _, _)| media_path.starts_with(mp.as_str()))
        .max_by_key(|(mp, _, _)| mp.len())
        .or_else(|| mounts.iter().find(|(mp, _, _)| mp == "/"))
        .map(|(_, total, free)| DiskInfo {
            total: *total,
            free: *free,
        })
}

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

    #[test]
    fn picks_longest_matching_mount() {
        // Both "/" and "/media" prefix the path; the dedicated volume must win.
        let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
        let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
        assert_eq!((d.total, d.free), (200, 150));
    }

    #[test]
    fn falls_back_to_root_when_no_specific_mount_matches() {
        let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
        // "/var/lib" is only prefixed by "/".
        let d = select_disk(&mounts, "/var/lib/data").unwrap();
        assert_eq!((d.total, d.free), (100, 40));
    }

    #[test]
    fn relative_path_uses_root_fallback() {
        let mounts = vec![("/".to_string(), 100, 40)];
        // A relative path prefixes nothing, so the explicit "/" fallback applies.
        let d = select_disk(&mounts, "media/originals").unwrap();
        assert_eq!((d.total, d.free), (100, 40));
    }

    #[test]
    fn none_when_no_mount_matches_and_no_root() {
        // No "/" present and nothing prefixes the relative path → unknown (fail-open).
        let mounts = vec![("/data".to_string(), 100, 40)];
        assert!(select_disk(&mounts, "relative/path").is_none());
    }

    #[test]
    fn none_on_empty_mount_table() {
        assert!(select_disk(&[], "/media/x").is_none());
    }
}
