//! Custom UI background (W16): turn a user-picked image into a capped PNG to
//! render behind the whole UI, plus the legibility scrim drawn over it.
//!
//! This is the pure, unit-testable seam — `process_background` decodes/downscales
//! arbitrary input defensively (same caution as avatar uploads) and `scrim_color`
//! computes the overlay tint. The file I/O, the `rfd` picker, and the iced
//! `stack!` that layers image → scrim → UI all live at the app edge in
//! `src/app/mod.rs`. The background is **local-only** — never sent to peers — so
//! there's no gossip-frame budget here (hence a much larger size cap than avatars).

use iced::Color;

/// Longest side a custom background is downscaled to on ingest (aspect preserved,
/// never upscaled). Big enough to look crisp filling the window, small enough to
/// decode and cache cheaply. Local-only, so this is generous vs. the avatar cap.
pub const BACKGROUND_MAX_PX: u32 = 1920;

/// Default scrim strength. `0.0` = the image shows at full strength, `1.0` = it's
/// fully hidden behind the theme's base colour. Half keeps a photo clearly visible
/// while text and cards stay readable over it.
pub const DEFAULT_DIM: f32 = 0.5;

/// Decode an arbitrary user image (png/jpeg/…), downscale so its longest side is
/// at most [`BACKGROUND_MAX_PX`] (aspect preserved; smaller images are left as-is,
/// never upscaled), and re-encode as PNG bytes ready to write to disk. Decoding is
/// bounded by the `image` crate's defaults so a malformed/huge file is rejected
/// rather than exhausting memory. Errors come back as a message for the UI.
pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
    let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
    // Only ever shrink. `resize` preserves aspect, fitting within the box; a
    // higher-quality filter than `thumbnail` since a background fills the window.
    let scaled = if img.width() > BACKGROUND_MAX_PX || img.height() > BACKGROUND_MAX_PX {
        img.resize(
            BACKGROUND_MAX_PX,
            BACKGROUND_MAX_PX,
            image::imageops::FilterType::Lanczos3,
        )
    } else {
        img
    };
    let mut png = std::io::Cursor::new(Vec::new());
    scaled
        .write_to(&mut png, image::ImageFormat::Png)
        .map_err(|e| format!("Couldn't encode image: {e}"))?;
    Ok(png.into_inner())
}

/// A filesystem-safe, app-owned filename for the processed PNG of a per-game
/// background (W18), derived from the game's stable id by hashing rather than
/// embedding the raw id: keeps the name short and safe (ids contain `:` and
/// arbitrary executable basenames) and avoids leaking the id into the filesystem.
/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always
/// maps to the same file.
pub fn game_background_filename(game_id: &str) -> String {
    // FNV-1a, 64-bit.
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
    for b in game_id.as_bytes() {
        hash ^= *b as u64;
        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("game-bg-{hash:016x}.png")
}

/// The legibility scrim drawn between the background image and the UI: the active
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
/// recedes the image so body text and panel chrome stay readable, and it re-tints
/// per theme since `base` comes from the active palette.
pub fn scrim_color(base: Color, dim: f32) -> Color {
    Color {
        a: dim.clamp(0.0, 1.0),
        ..base
    }
}

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

    /// A valid PNG of the given size, as raw bytes (test helper).
    fn make_png(w: u32, h: u32) -> Vec<u8> {
        let img = image::DynamicImage::new_rgb8(w, h);
        let mut buf = std::io::Cursor::new(Vec::new());
        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
        buf.into_inner()
    }

    #[test]
    fn process_background_downscales_oversized() {
        // A 4000x2000 image is shrunk so the longest side is BACKGROUND_MAX_PX,
        // aspect preserved, and the result re-decodes as a PNG within bounds.
        let raw = make_png(4000, 2000);
        let png = process_background(&raw).expect("should process");
        let decoded = image::load_from_memory(&png).unwrap();
        assert_eq!(decoded.width().max(decoded.height()), BACKGROUND_MAX_PX);
        assert_eq!(decoded.width(), BACKGROUND_MAX_PX);
        assert_eq!(decoded.height(), BACKGROUND_MAX_PX / 2); // 2:1 aspect kept
    }

    #[test]
    fn process_background_leaves_small_images_unscaled() {
        let raw = make_png(640, 480);
        let png = process_background(&raw).expect("should process");
        let decoded = image::load_from_memory(&png).unwrap();
        assert_eq!((decoded.width(), decoded.height()), (640, 480));
    }

    #[test]
    fn process_background_rejects_non_image() {
        assert!(process_background(b"definitely not an image").is_err());
    }

    #[test]
    fn game_background_filename_is_stable_safe_and_distinct() {
        let a = game_background_filename("steam:730");
        // Stable for the same id.
        assert_eq!(a, game_background_filename("steam:730"));
        // Distinct ids → distinct files (no `:` or path chars leak through).
        assert_ne!(a, game_background_filename("exe:hl2_linux"));
        assert!(a.starts_with("game-bg-") && a.ends_with(".png"));
        assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\'));
    }

    #[test]
    fn scrim_color_sets_alpha_and_keeps_rgb() {
        let base = Color::from_rgb(0.1, 0.2, 0.3);
        let s = scrim_color(base, 0.5);
        assert_eq!((s.r, s.g, s.b), (0.1, 0.2, 0.3));
        assert!((s.a - 0.5).abs() < f32::EPSILON);
    }

    #[test]
    fn scrim_color_clamps_dim() {
        let base = Color::BLACK;
        assert!((scrim_color(base, -1.0).a - 0.0).abs() < f32::EPSILON);
        assert!((scrim_color(base, 2.0).a - 1.0).abs() < f32::EPSILON);
    }
}
