//! Chat file attachments: the compact descriptor that rides a gossip chat
//! message, plus the pure validation/sanitization seams for the file-transfer
//! plane.
//!
//! Attachment **bytes do not travel over gossip** — gossip is a small-frame
//! broadcast plane (see `avatar` for why image bytes there are hard-capped to
//! tens of KB). Instead a chat message carries a [`ChatAttachment`] *descriptor*
//! (name, size, kind, id); the sender serves the actual bytes over the dedicated
//! file ALPN (`protocol::FILES_ALPN`) via direct QUIC streams, and recipients
//! fetch them point-to-point. Everything in this module is dependency-light and
//! pure so it can be unit-tested away from the network and the GUI.

use serde::{Deserialize, Serialize};

/// Hard ceiling on a single attachment's byte size. Bounds the memory a peer can
/// make us hold (when fetching) or serve, and the time a transfer can take.
/// 25 MiB comfortably covers phone photos and ordinary documents.
pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;

/// Max decoded pixels per side for an inline image preview. Defends against a
/// decode-bomb (a small file that expands to an enormous bitmap), independent of
/// the byte cap. Applied via `image::Limits` when validating/decoding.
pub const MAX_IMAGE_PX: u32 = 4096;

/// Max total decoded pixels, applied on top of the per-side [`MAX_IMAGE_PX`]
/// limit. The per-side cap alone still admits a 4096×4096 ≈ 16.8 MP bitmap
/// (~64 MiB transient RGBA); this bounds the worst-case decode allocation while
/// still clearing common 12 MP phone photos (4032×3024 ≈ 12.2 MP).
pub const MAX_IMAGE_TOTAL_PIXELS: u64 = 14_000_000;

/// Max pixels per side of the downscaled inline preview handed to the renderer.
/// Original bytes are kept only for Save; the chat column never needs more than
/// this (it displays at ~260 px, and the lightbox at window size).
pub const IMAGE_PREVIEW_MAX_SIDE: u32 = 1600;

/// Largest declared size an image attachment may auto-fetch at. Anything larger
/// (or any skipped/evicted image) renders a "Load image" button instead; a
/// manual click may use the full [`MAX_ATTACHMENT_BYTES`] cap.
pub const MAX_AUTO_IMAGE_BYTES: u64 = 4 * 1024 * 1024;

/// Longest filename we keep and display. Keeps the gossip descriptor compact and
/// the UI tidy; the real bytes are unaffected.
pub const MAX_FILENAME_LEN: usize = 96;

/// A 32-byte opaque id identifying one attachment for the fetch request. Minted
/// randomly per attachment by the sender (see core); the transfer itself is
/// authenticated + encrypted + room-member gated, so the id only needs to be a
/// hard-to-guess handle into the sender's serve store, not a content hash.
pub type AttachmentId = [u8; 32];

/// How the receiver should present an attachment. A *hint* derived from the
/// sender's content sniff — never trusted for a safety decision. The receiver
/// re-validates image bytes itself before decoding, and falls back to a file
/// chip if an "Image" doesn't actually decode.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum AttachmentKind {
    Image,
    File,
}

/// The descriptor carried inside a `GossipMessage::Chat`. Compact by design: it
/// holds no file bytes, only what the UI needs to render a placeholder/chip and
/// what a fetch needs to pull the bytes.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct ChatAttachment {
    /// Sanitized display filename (already path-stripped — see
    /// [`sanitize_filename`]). Never used as a filesystem path on receipt without
    /// the user choosing a save location.
    pub name: String,
    /// Byte length of the file. Bounds the fetch read; must be
    /// `<= MAX_ATTACHMENT_BYTES` (enforced by [`size_within_cap`]).
    pub size: u64,
    /// Presentation hint (image vs. generic file).
    pub kind: AttachmentKind,
    /// Opaque handle the receiver writes on the file plane to request the bytes.
    pub id: AttachmentId,
}

/// Sanitize an arbitrary (possibly hostile) filename for display and as a
/// save-dialog default. Strips any directory component (both `/` and `\`),
/// removes control characters, collapses whitespace, trims, caps the length
/// while trying to preserve a short extension, and rejects the `.`/`..` traps.
/// Always returns a non-empty, path-component-free name (falls back to `file`).
pub fn sanitize_filename(raw: &str) -> String {
    // Take only the final *non-empty* path component, defeating
    // `../../etc/passwd`, `C:\foo\bar`, embedded separators, and trailing slashes
    // (`a/b/c/` → `c`).
    let base = raw
        .rsplit(['/', '\\'])
        .find(|s| !s.trim().is_empty())
        .unwrap_or("")
        .trim();

    // Drop control chars and the same bidi/zero-width spoofing format chars
    // stripped from display names (a U+202E override can visually reverse an
    // extension, e.g. "photo\u{202E}gnp.exe" renders as "photoexe.png").
    // Ordinary non-ASCII filenames pass through untouched.
    let cleaned: String = base
        .chars()
        .filter(|c| !c.is_control() && !crate::sanitize::is_spoofing_format_char(*c))
        .collect();
    let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
    let collapsed = collapsed.trim_matches('.').trim();

    if collapsed.is_empty() {
        return "file".to_string();
    }
    if collapsed.chars().count() <= MAX_FILENAME_LEN {
        return collapsed.to_string();
    }

    // Too long: keep the extension (if short + sane) and truncate the stem.
    if let Some((stem, ext)) = collapsed.rsplit_once('.')
        && !ext.is_empty()
        && ext.chars().count() <= 8
        && ext.chars().all(|c| c.is_ascii_alphanumeric())
    {
        let keep = MAX_FILENAME_LEN.saturating_sub(ext.chars().count() + 1);
        let truncated: String = stem.chars().take(keep).collect();
        return format!("{truncated}.{ext}");
    }
    collapsed.chars().take(MAX_FILENAME_LEN).collect()
}

/// Whether a declared/observed size is within the transfer cap and non-zero.
/// Used both when sending (reject before serving) and when fetching (reject a
/// descriptor before opening a stream).
pub fn size_within_cap(size: u64) -> bool {
    size > 0 && size <= MAX_ATTACHMENT_BYTES
}

/// Sniff the leading bytes for a known image container, to set the attachment
/// *kind* hint at send time. Recognizes PNG, JPEG, GIF, WebP, and BMP. This is a
/// presentation hint only — actual inline rendering still depends on the bytes
/// decoding (we only build image features for PNG/JPEG), with a file-chip
/// fallback otherwise.
pub fn is_probably_image(bytes: &[u8]) -> bool {
    let b = bytes;
    let png = b.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
    let jpeg = b.starts_with(&[0xFF, 0xD8, 0xFF]);
    let gif = b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a");
    let bmp = b.starts_with(b"BM");
    let webp = b.len() >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP";
    png || jpeg || gif || bmp || webp
}

/// Sniff the leading bytes for an audio container supported by the inline clip
/// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side
/// check confirms that a filename-based player hint actually contains WAV, MP3,
/// Ogg Vorbis, or FLAC data before playback is attempted.
pub fn is_probably_audio(bytes: &[u8]) -> bool {
    let flac = bytes.starts_with(b"fLaC");
    let ogg = bytes.starts_with(b"OggS");
    let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE";
    let mp3_id3 = bytes.starts_with(b"ID3");
    let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0;
    flac || ogg || wav || mp3_id3 || mp3_frame
}

/// Whether a sanitized attachment name has an extension supported by the
/// inline audio player. This is only a pre-fetch presentation hint; fetched
/// bytes are confirmed with [`is_probably_audio`] before being decoded.
pub fn looks_like_audio_name(name: &str) -> bool {
    let Some((_, extension)) = name.rsplit_once('.') else {
        return false;
    };
    matches!(
        extension.to_ascii_lowercase().as_str(),
        "wav" | "mp3" | "ogg" | "oga" | "flac"
    )
}

/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
/// sniffs as an image container, else [`AttachmentKind::File`].
pub fn classify(bytes: &[u8]) -> AttachmentKind {
    if is_probably_image(bytes) {
        AttachmentKind::Image
    } else {
        AttachmentKind::File
    }
}

/// Defensively decode image bytes under strict pixel limits to confirm they're a
/// real, sane image before we hand them to the renderer. Returns the decoded
/// dimensions on success. Guards against decode-bombs (small file → huge bitmap)
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
/// `image` feature set; anything else returns `None` and the caller shows a chip.
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
    let img = decode_image_bounded(bytes)?;
    Some((img.width(), img.height()))
}

/// Shared bounded decode: header-check the dimensions (per-side AND total-pixel
/// limits) BEFORE decoding, then decode under `image::Limits` as defense in
/// depth. The precheck reads only the container header, so an over-limit bomb is
/// rejected without paying its decode cost.
fn decode_image_bounded(bytes: &[u8]) -> Option<image::DynamicImage> {
    let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
        .with_guessed_format()
        .ok()?;
    let (w, h) = reader.into_dimensions().ok()?;
    if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
        return None;
    }
    if u64::from(w) * u64::from(h) > MAX_IMAGE_TOTAL_PIXELS {
        return None;
    }
    let mut limits = image::Limits::default();
    limits.max_image_width = Some(MAX_IMAGE_PX);
    limits.max_image_height = Some(MAX_IMAGE_PX);
    let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
        .with_guessed_format()
        .ok()?;
    reader.limits(limits);
    let img = reader.decode().ok()?;
    // Decoded size must match the header the precheck approved.
    if img.width() != w || img.height() != h {
        return None;
    }
    Some(img)
}

/// A decoded, display-ready inline preview: RGBA pixels downscaled so neither
/// side exceeds [`IMAGE_PREVIEW_MAX_SIDE`]. `rgba.len() == width * height * 4`,
/// which is also the preview's decoded-budget weight in the attachment cache.
pub struct ImagePreview {
    pub width: u32,
    pub height: u32,
    pub rgba: Vec<u8>,
}

/// Decode image bytes under the same limits as [`validate_image_bytes`] and
/// build the downscaled inline preview. The full-resolution bitmap exists only
/// transiently here; the renderer is never handed more than
/// [`IMAGE_PREVIEW_MAX_SIDE`]² pixels. Returns `None` for anything that fails
/// validation (caller falls back to a chip / failure row).
pub fn decode_preview(bytes: &[u8]) -> Option<ImagePreview> {
    let img = decode_image_bounded(bytes)?;
    let img = if img.width() > IMAGE_PREVIEW_MAX_SIDE || img.height() > IMAGE_PREVIEW_MAX_SIDE {
        // `thumbnail` preserves aspect ratio within the bounding box.
        img.thumbnail(IMAGE_PREVIEW_MAX_SIDE, IMAGE_PREVIEW_MAX_SIDE)
    } else {
        img
    };
    let rgba = img.into_rgba8();
    let (width, height) = (rgba.width(), rgba.height());
    Some(ImagePreview {
        width,
        height,
        rgba: rgba.into_raw(),
    })
}

/// Estimated decoded RGBA cost of a preview, the weight counted against the
/// attachment cache's decoded-byte budget (`width * height * 4`).
pub fn preview_rgba_cost(width: u32, height: u32) -> usize {
    (width as usize)
        .saturating_mul(height as usize)
        .saturating_mul(4)
}

/// Read at most [`MAX_ATTACHMENT_BYTES`] bytes from `r`. Returns `Ok(None)` if
/// the source holds even one byte more (detected by reading cap + 1), so a huge
/// or unbounded source is never fully buffered. Pure over `Read` for tests; the
/// picker wraps it via [`read_file_capped`].
pub fn read_capped<R: std::io::Read>(r: R) -> std::io::Result<Option<Vec<u8>>> {
    use std::io::Read as _;
    let mut buf = Vec::new();
    let mut limited = r.take(MAX_ATTACHMENT_BYTES + 1);
    limited.read_to_end(&mut buf)?;
    if buf.len() as u64 > MAX_ATTACHMENT_BYTES {
        return Ok(None);
    }
    Ok(Some(buf))
}

/// Read a picked file, bounded by [`MAX_ATTACHMENT_BYTES`]. Checks metadata
/// first to reject an obviously-oversized file without opening it, but keeps the
/// bounded read regardless — metadata can race (the file can grow after the
/// check) or be unavailable through a portal. `Ok(None)` = over the cap.
pub fn read_file_capped(path: &std::path::Path) -> std::io::Result<Option<Vec<u8>>> {
    if let Ok(meta) = std::fs::metadata(path)
        && meta.len() > MAX_ATTACHMENT_BYTES
    {
        return Ok(None);
    }
    read_capped(std::fs::File::open(path)?)
}

/// Cap on how many blobs the session serve store retains at once (sent chat
/// attachments plus the current/next broadcast music tracks).
pub const SERVED_FILES_MAX_ENTRIES: usize = 16;

/// Byte budget for the serve store. Without it, a sender's own session could
/// grow unbounded at up to [`MAX_ATTACHMENT_BYTES`] per send (Phase 3C).
pub const SERVED_FILES_MAX_BYTES: usize = 128 * 1024 * 1024;

/// Count- and byte-budgeted FIFO store of blobs we serve to room members over
/// the file plane. Evicting an id makes a later request for it read as an empty
/// body — the existing "sender no longer has the file" response — never stale
/// or aliased bytes. Pure (no locks/IO) so budgets are unit-testable; the
/// transport wraps it in its own mutex.
#[derive(Debug, Default)]
pub struct ServeStore {
    entries: std::collections::HashMap<AttachmentId, std::sync::Arc<Vec<u8>>>,
    /// Present ids in insertion order; the front is the eviction candidate.
    order: std::collections::VecDeque<AttachmentId>,
    total_bytes: usize,
}

impl ServeStore {
    /// Insert or replace a blob, evicting oldest entries until the count and
    /// byte budgets fit. Replacement keeps the id's age and subtracts the old
    /// bytes before the new ones are counted. Returns `false` for a blob that
    /// alone exceeds the byte budget (not stored; an existing entry under the
    /// id is dropped rather than left stale).
    pub fn insert(&mut self, id: AttachmentId, bytes: std::sync::Arc<Vec<u8>>) -> bool {
        if let Some(old) = self.entries.get(&id) {
            self.total_bytes -= old.len();
        }
        if bytes.len() > SERVED_FILES_MAX_BYTES {
            if self.entries.remove(&id).is_some() {
                self.order.retain(|k| k != &id);
            }
            return false;
        }
        let replacing = self.entries.contains_key(&id);
        loop {
            let count_full = !replacing && self.entries.len() >= SERVED_FILES_MAX_ENTRIES;
            let bytes_full = self.total_bytes + bytes.len() > SERVED_FILES_MAX_BYTES;
            if !count_full && !bytes_full {
                break;
            }
            let Some(victim) = self.order.iter().find(|k| **k != id).copied() else {
                break;
            };
            self.remove(&victim);
        }
        if !replacing {
            self.order.push_back(id);
        }
        self.total_bytes += bytes.len();
        self.entries.insert(id, bytes);
        true
    }

    pub fn get(&self, id: &AttachmentId) -> Option<std::sync::Arc<Vec<u8>>> {
        self.entries.get(id).cloned()
    }

    pub fn remove(&mut self, id: &AttachmentId) {
        if let Some(old) = self.entries.remove(id) {
            self.total_bytes -= old.len();
            self.order.retain(|k| k != id);
        }
    }

    pub fn clear(&mut self) {
        self.entries.clear();
        self.order.clear();
        self.total_bytes = 0;
    }

    #[cfg(test)]
    fn len(&self) -> usize {
        self.entries.len()
    }
}

/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
/// bytes). Anything else is rejected so a peer can't send a malformed/oversized
/// request frame. Pure half of the serve handler.
pub fn parse_request(bytes: &[u8]) -> Option<AttachmentId> {
    if bytes.len() != 32 {
        return None;
    }
    let mut id = [0u8; 32];
    id.copy_from_slice(bytes);
    Some(id)
}

/// A human-readable size like `2.3 MB` / `812 KB` / `40 B` for the file chip.
pub fn human_size(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = 1024 * KB;
    if bytes >= MB {
        format!("{:.1} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.0} KB", bytes as f64 / KB as f64)
    } else {
        format!("{bytes} B")
    }
}

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

    #[test]
    fn sanitize_strips_directory_traversal() {
        assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
        assert_eq!(sanitize_filename("/abs/path/photo.png"), "photo.png");
        assert_eq!(sanitize_filename(r"C:\Users\me\secret.doc"), "secret.doc");
        assert_eq!(sanitize_filename("a/b/c/"), "c");
    }

    #[test]
    fn sanitize_rejects_dot_traps_and_empty() {
        assert_eq!(sanitize_filename(""), "file");
        assert_eq!(sanitize_filename("."), "file");
        assert_eq!(sanitize_filename(".."), "file");
        assert_eq!(sanitize_filename("   "), "file");
        assert_eq!(sanitize_filename("/"), "file");
    }

    #[test]
    fn sanitize_removes_control_chars_and_collapses_ws() {
        // Control chars (incl. tab/newline) are stripped entirely.
        assert_eq!(sanitize_filename("my\tphoto\n.png"), "myphoto.png");
        assert_eq!(sanitize_filename("a\u{0000}b.txt"), "ab.txt");
        // Real spaces are collapsed but preserved.
        assert_eq!(sanitize_filename("my  photo .png"), "my photo .png");
    }

    #[test]
    fn sanitize_caps_length_preserving_extension() {
        let long_stem = "x".repeat(200);
        let name = format!("{long_stem}.png");
        let out = sanitize_filename(&name);
        assert!(
            out.chars().count() <= MAX_FILENAME_LEN,
            "len was {}",
            out.chars().count()
        );
        assert!(out.ends_with(".png"), "extension preserved: {out}");
    }

    #[test]
    fn size_cap_bounds() {
        assert!(!size_within_cap(0));
        assert!(size_within_cap(1));
        assert!(size_within_cap(MAX_ATTACHMENT_BYTES));
        assert!(!size_within_cap(MAX_ATTACHMENT_BYTES + 1));
    }

    #[test]
    fn image_sniffing_recognizes_containers() {
        assert!(is_probably_image(&[
            0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0
        ]));
        assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
        assert!(is_probably_image(b"GIF89a...."));
        let mut webp = b"RIFF".to_vec();
        webp.extend_from_slice(&[0, 0, 0, 0]);
        webp.extend_from_slice(b"WEBP");
        assert!(is_probably_image(&webp));
        assert!(!is_probably_image(b"%PDF-1.7"));
        assert!(!is_probably_image(b""));
    }

    #[test]
    fn audio_sniffing_recognizes_supported_containers() {
        assert!(is_probably_audio(b"fLaC\0\0\0\x22"));
        assert!(is_probably_audio(b"OggS\0\x02"));

        let mut wav = b"RIFF".to_vec();
        wav.extend_from_slice(&[0, 0, 0, 0]);
        wav.extend_from_slice(b"WAVE");
        assert!(is_probably_audio(&wav));

        assert!(is_probably_audio(b"ID3\x04\0\0"));
        assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64]));
    }

    #[test]
    fn audio_sniffing_disambiguates_wav_from_webp() {
        let mut wav = b"RIFF".to_vec();
        wav.extend_from_slice(&[0, 0, 0, 0]);
        wav.extend_from_slice(b"WAVE");
        assert!(is_probably_audio(&wav));
        assert!(!is_probably_image(&wav));

        let mut webp = b"RIFF".to_vec();
        webp.extend_from_slice(&[0, 0, 0, 0]);
        webp.extend_from_slice(b"WEBP");
        assert!(is_probably_image(&webp));
        assert!(!is_probably_audio(&webp));
    }

    #[test]
    fn audio_sniffing_rejects_non_audio() {
        assert!(!is_probably_audio(b"%PDF-1.7"));
        assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G']));
        assert!(!is_probably_audio(&[]));
        assert!(!is_probably_audio(&[0xFF]));
    }

    #[test]
    fn audio_name_detection_is_case_insensitive() {
        for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] {
            assert!(looks_like_audio_name(name), "{name}");
        }
        assert!(looks_like_audio_name("VOICE.MP3"));
        assert!(looks_like_audio_name("mix.FlAc"));
        assert!(!looks_like_audio_name("recording"));
        assert!(!looks_like_audio_name("notes.pdf"));
        assert!(!looks_like_audio_name("photo.webp"));
    }

    #[test]
    fn classify_maps_sniff_to_kind() {
        assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
        assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File);
        assert_eq!(classify(b"plain text"), AttachmentKind::File);
    }

    #[test]
    fn parse_request_requires_exact_32_bytes() {
        assert_eq!(parse_request(&[7u8; 32]), Some([7u8; 32]));
        assert_eq!(parse_request(&[7u8; 31]), None);
        assert_eq!(parse_request(&[7u8; 33]), None);
        assert_eq!(parse_request(&[]), None);
    }

    #[test]
    fn validate_image_rejects_garbage() {
        assert_eq!(validate_image_bytes(b"not an image"), None);
        assert_eq!(validate_image_bytes(&[]), None);
    }

    #[test]
    fn validate_image_accepts_a_real_png() {
        // Encode a tiny PNG in-memory, then validate it.
        let img = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30]));
        let mut buf = std::io::Cursor::new(Vec::new());
        image::DynamicImage::ImageRgb8(img)
            .write_to(&mut buf, image::ImageFormat::Png)
            .unwrap();
        assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
    }

    #[test]
    fn sanitize_strips_bidi_and_zero_width_spoofing_chars() {
        // U+202E would visually reverse the tail, disguising the extension.
        assert_eq!(sanitize_filename("photo\u{202E}gnp.exe"), "photognp.exe");
        assert_eq!(sanitize_filename("a\u{200B}b\u{FEFF}.txt"), "ab.txt");
        // Ordinary Unicode filenames pass through.
        assert_eq!(sanitize_filename("família_fotos.png"), "família_fotos.png");
        assert_eq!(sanitize_filename("日本語.pdf"), "日本語.pdf");
    }

    /// Encode a solid PNG of the given dimensions for limit tests.
    fn png_bytes(w: u32, h: u32) -> Vec<u8> {
        let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30]));
        let mut buf = std::io::Cursor::new(Vec::new());
        image::DynamicImage::ImageRgb8(img)
            .write_to(&mut buf, image::ImageFormat::Png)
            .unwrap();
        buf.into_inner()
    }

    #[test]
    fn validate_image_rejects_excessive_total_pixels() {
        // Both sides within MAX_IMAGE_PX, but 4096 * 4096 > MAX_IMAGE_TOTAL_PIXELS.
        assert!(u64::from(MAX_IMAGE_PX) * u64::from(MAX_IMAGE_PX) > MAX_IMAGE_TOTAL_PIXELS);
        assert_eq!(validate_image_bytes(&png_bytes(4096, 4096)), None);
        // A 12 MP phone-photo shape passes both limits.
        assert_eq!(
            validate_image_bytes(&png_bytes(4032, 3024)),
            Some((4032, 3024))
        );
    }

    #[test]
    fn preview_downscales_to_max_side_preserving_aspect() {
        // Wide: 3200x400 → 1600x200.
        let p = decode_preview(&png_bytes(3200, 400)).unwrap();
        assert_eq!((p.width, p.height), (1600, 200));
        assert_eq!(p.rgba.len(), preview_rgba_cost(1600, 200));
        // Tall: 400x3200 → 200x1600.
        let p = decode_preview(&png_bytes(400, 3200)).unwrap();
        assert_eq!((p.width, p.height), (200, 1600));
        // Square over the side cap: 2000x2000 → 1600x1600.
        let p = decode_preview(&png_bytes(2000, 2000)).unwrap();
        assert_eq!((p.width, p.height), (1600, 1600));
        // At/under the cap is untouched.
        let p = decode_preview(&png_bytes(1600, 900)).unwrap();
        assert_eq!((p.width, p.height), (1600, 900));
        let p = decode_preview(&png_bytes(4, 3)).unwrap();
        assert_eq!((p.width, p.height), (4, 3));
        assert_eq!(p.rgba.len(), preview_rgba_cost(4, 3));
    }

    #[test]
    fn preview_rejects_what_validation_rejects() {
        assert!(decode_preview(b"not an image").is_none());
        assert!(decode_preview(&png_bytes(4096, 4096)).is_none());
    }

    #[test]
    fn read_capped_stops_at_cap_plus_one() {
        // Under the cap: full read.
        let small = vec![7u8; 1024];
        assert_eq!(
            read_capped(std::io::Cursor::new(&small))
                .unwrap()
                .as_deref(),
            Some(&small[..])
        );
        // Exactly at the cap: accepted. `repeat` is endless, `take` proves the
        // reader is bounded rather than draining the source.
        let at_cap = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES);
        let got = read_capped(at_cap).unwrap().unwrap();
        assert_eq!(got.len() as u64, MAX_ATTACHMENT_BYTES);
        // One byte over: rejected, and only cap + 1 bytes were ever buffered
        // (an unbounded source returns instead of allocating forever).
        let over = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES + 1);
        assert_eq!(read_capped(over).unwrap(), None);
        let endless = std::io::repeat(1);
        assert_eq!(read_capped(endless).unwrap(), None);
    }

    #[test]
    fn human_size_units() {
        assert_eq!(human_size(40), "40 B");
        assert_eq!(human_size(2048), "2 KB");
        assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
    }

    #[test]
    fn serve_store_count_and_byte_eviction_fifo() {
        use std::sync::Arc;
        let mut s = ServeStore::default();
        let blob = |n: u8, len: usize| ([n; 32], Arc::new(vec![n; len]));
        // Count cap: entry 0 is evicted when the 17th arrives.
        for n in 0..=SERVED_FILES_MAX_ENTRIES as u8 {
            let (id, b) = blob(n, 8);
            assert!(s.insert(id, b));
        }
        assert_eq!(s.len(), SERVED_FILES_MAX_ENTRIES);
        assert!(s.get(&[0u8; 32]).is_none(), "oldest evicted by count");
        assert!(s.get(&[1u8; 32]).is_some());
        // Byte budget: two ~half-budget blobs evict everything older.
        let half = SERVED_FILES_MAX_BYTES / 2;
        let (a, ab) = blob(100, half);
        let (b, bb) = blob(101, half);
        assert!(s.insert(a, ab));
        assert!(s.insert(b, bb));
        assert!(s.get(&a).is_some());
        assert!(s.get(&b).is_some());
        assert!(s.get(&[1u8; 32]).is_none(), "evicted for byte budget");
        // A third half-budget blob evicts `a` (oldest), keeps `b`.
        let (c, cb) = blob(102, half);
        assert!(s.insert(c, cb));
        assert!(s.get(&a).is_none());
        assert!(s.get(&b).is_some());
        assert!(s.get(&c).is_some());
    }

    #[test]
    fn serve_store_replacement_accounting_and_remove_clear() {
        use std::sync::Arc;
        let mut s = ServeStore::default();
        let id = [9u8; 32];
        assert!(s.insert(id, Arc::new(vec![1; SERVED_FILES_MAX_BYTES - 10])));
        // Replacing the near-budget blob must subtract its old bytes first —
        // otherwise this same-id replacement would evict itself.
        assert!(s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES - 5])));
        assert_eq!(s.get(&id).unwrap()[0], 2);
        assert_eq!(s.len(), 1);
        s.remove(&id);
        assert!(s.get(&id).is_none());
        // Removed bytes were released: the budget admits a full-size blob again.
        assert!(s.insert(id, Arc::new(vec![3; SERVED_FILES_MAX_BYTES])));
        s.clear();
        assert_eq!(s.len(), 0);
        assert!(s.insert(id, Arc::new(vec![4; SERVED_FILES_MAX_BYTES])));
    }

    #[test]
    fn serve_store_rejects_individually_overweight_blob() {
        use std::sync::Arc;
        let mut s = ServeStore::default();
        let id = [7u8; 32];
        assert!(s.insert(id, Arc::new(vec![1; 8])));
        assert!(!s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES + 1])));
        // The stale small blob is gone too — a fetch reads "no longer has it",
        // never old bytes under a replaced id.
        assert!(s.get(&id).is_none());
        assert_eq!(s.len(), 0);
    }

    #[test]
    fn attachment_descriptor_round_trips_json() {
        let a = ChatAttachment {
            name: "photo.png".to_string(),
            size: 12345,
            kind: AttachmentKind::Image,
            id: [9u8; 32],
        };
        let bytes = serde_json::to_vec(&a).unwrap();
        let back: ChatAttachment = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(a, back);
    }
}
