//! Avatar helpers: a participant's chosen [`Avatar`] plus the deterministic
//! monogram + colour used as the fallback.
//!
//! Every participant gets a visual avatar (W4). When they haven't chosen a preset
//! or uploaded an image, we fall back to a *monogram*: their initial(s) on a
//! colour deterministically derived from a stable key (their node id, or their
//! name where the id isn't available, e.g. a chat line). Pure + dependency-free
//! so it's unit-testable and needs no image decoding for the common case.

use serde::{Deserialize, Serialize};

/// Number of bundled preset avatars (`preset1.png`..`preset6.png`).
pub const PRESET_COUNT: u8 = 6;

/// The raw PNG bytes of each bundled preset, embedded so they're available
/// offline and identical on every peer. Index 0 = preset 1, etc.
pub const PRESET_PNGS: [&[u8]; PRESET_COUNT as usize] = [
    include_bytes!("../assets/avatars/preset1.png"),
    include_bytes!("../assets/avatars/preset2.png"),
    include_bytes!("../assets/avatars/preset3.png"),
    include_bytes!("../assets/avatars/preset4.png"),
    include_bytes!("../assets/avatars/preset5.png"),
    include_bytes!("../assets/avatars/preset6.png"),
];

/// Longest side a custom avatar is downscaled to on ingest (both our own upload
/// and the limit incoming peer images are validated against). Small on purpose:
/// avatars render tiny, and it keeps the transported bytes well within the gossip
/// frame budget.
pub const CUSTOM_MAX_PX: u32 = 128;

/// Hard cap on the base64 length of a custom avatar. Bounds the presence payload
/// (which rides a 64 KB gossip frame) and the memory a malicious peer can make us
/// hold. A 128px PNG is comfortably under this; anything larger is rejected.
pub const CUSTOM_MAX_B64: usize = 48 * 1024;

/// A participant's chosen avatar. Rides the gossip presence plane (`PeerState`)
/// and is persisted in `AppConfig`. `Monogram` is the default fallback (rendered
/// from name + a colour key, no image). `Preset(i)` selects a bundled preset by
/// zero-based index. `Custom` carries a base64-encoded, hard-capped PNG (W4
/// Phase 3): produced by [`process_upload`] for our own uploads, and validated by
/// [`sanitize_incoming`] for untrusted peer ones.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Avatar {
    /// Initials-on-colour fallback.
    #[default]
    Monogram,
    /// A bundled preset by zero-based index; out-of-range falls back to monogram.
    Preset(u8),
    /// A custom image as a base64-encoded PNG (capped, see [`CUSTOM_MAX_B64`]).
    Custom(String),
}

impl Avatar {
    /// The embedded PNG bytes for this avatar, or `None` when it isn't a preset
    /// (or the preset index is out of range, which renders as a monogram).
    pub fn preset_png(&self) -> Option<&'static [u8]> {
        match self {
            Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(),
            _ => None,
        }
    }

    /// The decoded PNG bytes for a custom avatar, or `None` for monogram/preset
    /// (or if the stored base64 is malformed). Used to build an image handle.
    pub fn custom_png(&self) -> Option<Vec<u8>> {
        use base64::Engine;
        match self {
            Avatar::Custom(b64) => base64::engine::general_purpose::STANDARD
                .decode(b64.as_bytes())
                .ok(),
            _ => None,
        }
    }

    /// Validate an avatar that arrived from an untrusted peer (over presence),
    /// returning a safe value. Monogram/Preset pass through (an out-of-range
    /// preset just renders as a monogram). A `Custom` is accepted only if its
    /// base64 is within [`CUSTOM_MAX_B64`], decodes, and the PNG decodes within
    /// [`CUSTOM_MAX_PX`] bounds (guards against decompression bombs / junk);
    /// otherwise it's downgraded to a monogram. Re-encode-free: we keep the
    /// original (already-validated) base64 to avoid re-deriving it every receipt.
    pub fn sanitize_incoming(self) -> Avatar {
        match self {
            Avatar::Custom(ref b64) => {
                if b64.len() <= CUSTOM_MAX_B64 && custom_b64_is_valid_png(b64) {
                    self
                } else {
                    Avatar::Monogram
                }
            }
            other => other,
        }
    }
}

/// Decode a base64 PNG and confirm it's a real PNG within [`CUSTOM_MAX_PX`]
/// bounds, using the `image` crate's decode limits so a malicious image can't
/// allocate unbounded memory. Returns false on any error.
fn custom_b64_is_valid_png(b64: &str) -> bool {
    use base64::Engine;
    let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64.as_bytes()) else {
        return false;
    };
    let mut limits = image::Limits::default();
    // Cap dimensions a bit above our resize target to allow for slight slack.
    limits.max_image_width = Some(CUSTOM_MAX_PX * 2);
    limits.max_image_height = Some(CUSTOM_MAX_PX * 2);
    let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes));
    reader.set_format(image::ImageFormat::Png);
    reader.limits(limits);
    match reader.decode() {
        Ok(img) => img.width() <= CUSTOM_MAX_PX * 2 && img.height() <= CUSTOM_MAX_PX * 2,
        Err(_) => false,
    }
}

/// Process a user-picked image file (arbitrary png/jpeg bytes) into a capped
/// custom [`Avatar`]: decode, downscale so the longest side is [`CUSTOM_MAX_PX`]
/// (aspect preserved), re-encode as PNG, base64. Errors (undecodable, or the
/// result somehow exceeds the cap) are returned as a message for the UI.
pub fn process_upload(raw: &[u8]) -> Result<Avatar, String> {
    use base64::Engine;
    let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
    let small = img.thumbnail(CUSTOM_MAX_PX, CUSTOM_MAX_PX);
    let mut png = std::io::Cursor::new(Vec::new());
    small
        .write_to(&mut png, image::ImageFormat::Png)
        .map_err(|e| format!("Couldn't encode image: {e}"))?;
    let b64 = base64::engine::general_purpose::STANDARD.encode(png.into_inner());
    if b64.len() > CUSTOM_MAX_B64 {
        return Err("Image is too large even after resizing.".to_string());
    }
    Ok(Avatar::Custom(b64))
}

/// Curated palette the monogram background is chosen from. Picked to be distinct
/// and legible under the app's (mostly dark) themes; the matching text colour is
/// decided per-background by [`use_dark_text_on`]. Order is part of the stable
/// mapping — don't reorder without accepting that everyone's colour shifts.
pub const PALETTE: [(u8, u8, u8); 12] = [
    (0xE5, 0x73, 0x73), // red
    (0xE5, 0x9E, 0x57), // orange
    (0xE5, 0xC0, 0x7B), // amber
    (0x8C, 0xC2, 0x65), // green
    (0x5E, 0xC8, 0xA0), // teal
    (0x5C, 0xB3, 0xE5), // blue
    (0x6E, 0x8C, 0xE5), // azure
    (0x9A, 0x8C, 0xE5), // indigo
    (0xC0, 0x7B, 0xE5), // violet
    (0xE5, 0x7B, 0xC0), // pink
    (0xB0, 0x8B, 0x6E), // brown
    (0x8A, 0x9B, 0xA8), // slate
];

/// Pick a stable palette colour for `key` (a node id string, or a name). Uses an
/// FNV-1a hash so the same key always maps to the same colour across machines.
pub fn color_for_key(key: &str) -> (u8, u8, u8) {
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
    for b in key.as_bytes() {
        hash ^= u64::from(*b);
        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    }
    PALETTE[(hash % PALETTE.len() as u64) as usize]
}

/// Whether dark text is more legible than white on the given background, by
/// relative luminance (sRGB-weighted). Light backgrounds → dark text.
pub fn use_dark_text_on((r, g, b): (u8, u8, u8)) -> bool {
    let lum = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
    lum > 150.0
}

/// The 1–2 character monogram for a display name: the first letters of its first
/// two alphanumeric words, uppercased. Falls back to "?" when nothing usable
/// (empty name, or only emoji/punctuation) remains.
pub fn initials(name: &str) -> String {
    let mut firsts = name
        .split_whitespace()
        .filter_map(|w| w.chars().find(|c| c.is_alphanumeric()));
    match (firsts.next(), firsts.next()) {
        (Some(a), Some(b)) => format!("{}{}", a.to_uppercase(), b.to_uppercase()),
        (Some(a), None) => a.to_uppercase().to_string(),
        _ => "?".to_string(),
    }
}

/// A small content-addressed LRU cache mapping image bytes to a built value
/// (e.g. an `iced` image handle), so the SAME value is reused across redraws
/// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain
/// hash map:
///
/// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on
///    overflow, so a peer can't grow the cache without limit by publishing an
///    endless stream of distinct valid avatars.
/// 2. **Collision-safe** — a hit requires full byte equality, not just a matching
///    64-bit hash, so a hash collision can never return a different image's value.
///
/// Linear scan; intended for small `cap` (tens of entries).
pub struct ByteLru<V> {
    cap: usize,
    /// `(content hash, content bytes, value)`; back = most recently used.
    entries: Vec<(u64, Vec<u8>, V)>,
}

impl<V: Clone> ByteLru<V> {
    /// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
    pub fn new(cap: usize) -> Self {
        Self {
            cap: cap.max(1),
            entries: Vec::new(),
        }
    }

    /// Return the cached value for these exact `bytes`, building and inserting it
    /// on a miss (evicting the least-recently-used entry once over `cap`). A hit
    /// verifies full byte equality, so a 64-bit hash collision never returns the
    /// wrong value. A hit also refreshes the entry's recency.
    pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V {
        use std::hash::{Hash, Hasher};
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        bytes.hash(&mut hasher);
        self.get_or_insert_hashed(hasher.finish(), bytes, build)
    }

    /// Inner seam with the content `hash` supplied explicitly. Production callers
    /// use [`get_or_insert`]; tests use this to force a hash collision (different
    /// bytes, same hash) and exercise the byte-equality guard.
    fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V {
        if let Some(idx) = self
            .entries
            .iter()
            .position(|(h, b, _)| *h == hash && b.as_slice() == bytes)
        {
            // LRU touch: move the hit entry to the back (most recent).
            let entry = self.entries.remove(idx);
            let val = entry.2.clone();
            self.entries.push(entry);
            return val;
        }

        let val = build();
        if self.entries.len() >= self.cap {
            self.entries.remove(0); // evict least-recently-used
        }
        self.entries.push((hash, bytes.to_vec(), val.clone()));
        val
    }

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

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

    #[test]
    fn byte_lru_reuses_value_for_identical_bytes() {
        let mut lru: ByteLru<u32> = ByteLru::new(4);
        let mut next = 0u32;
        let mut build = |lru: &mut ByteLru<u32>, b: &[u8]| {
            lru.get_or_insert(b, || {
                next += 1;
                next
            })
        };
        // Same bytes → same value, built only once.
        assert_eq!(build(&mut lru, b"alice"), 1);
        assert_eq!(build(&mut lru, b"alice"), 1);
        // Different bytes → a freshly built value.
        assert_eq!(build(&mut lru, b"bob"), 2);
        assert_eq!(lru.len(), 2);
    }

    #[test]
    fn byte_lru_evicts_least_recently_used() {
        let mut lru: ByteLru<u32> = ByteLru::new(2);
        let mut n = 0u32;
        let mut ins = |lru: &mut ByteLru<u32>, b: &[u8]| {
            lru.get_or_insert(b, || {
                n += 1;
                n
            })
        };
        ins(&mut lru, b"a"); // -> 1
        ins(&mut lru, b"b"); // -> 2, cache = [a, b]
        ins(&mut lru, b"a"); // touch a, cache = [b, a]
        ins(&mut lru, b"c"); // evicts LRU (b), cache = [a, c]
        assert_eq!(lru.len(), 2);
        // `a` survived (recently touched) → still value 1, not rebuilt.
        assert_eq!(ins(&mut lru, b"a"), 1);
        // `b` was evicted → rebuilt with a new value.
        assert_eq!(ins(&mut lru, b"b"), 4);
    }

    #[test]
    fn byte_lru_byte_equality_survives_a_hash_collision() {
        // Force the SAME 64-bit hash for two DIFFERENT byte strings (the case a
        // bare-hash cache would alias — Tier C F-03 collision bug).
        let mut lru: ByteLru<u32> = ByteLru::new(4);
        assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 1), 1);
        // `bob` collides on the hash but differs in bytes → a MISS, built fresh,
        // NOT aliased to alice's value.
        assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 2), 2);
        // Both coexist; each re-lookup returns its own value (build closure unused).
        assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 99), 1);
        assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 99), 2);
        assert_eq!(lru.len(), 2);
    }

    #[test]
    fn initials_takes_first_two_words() {
        assert_eq!(initials("Alice"), "A");
        assert_eq!(initials("alice bob"), "AB");
        assert_eq!(initials("  spaced   out  name "), "SO");
    }

    #[test]
    fn initials_handles_unicode_and_skips_non_alnum() {
        assert_eq!(initials("héllo"), "H");
        // Leading emoji word is skipped; the next alphanumeric word is used.
        assert_eq!(initials("🎙 Mike"), "M");
    }

    #[test]
    fn initials_falls_back_to_question_mark() {
        assert_eq!(initials(""), "?");
        assert_eq!(initials("   "), "?");
        assert_eq!(initials("🎉🎊"), "?");
    }

    #[test]
    fn color_is_deterministic_and_in_palette() {
        let c1 = color_for_key("node-abc");
        let c2 = color_for_key("node-abc");
        assert_eq!(c1, c2, "same key must map to same colour");
        assert!(PALETTE.contains(&c1));
        // Different keys generally differ; at minimum the function is total.
        let _ = color_for_key("");
    }

    #[test]
    fn avatar_default_is_monogram() {
        assert_eq!(Avatar::default(), Avatar::Monogram);
        assert!(Avatar::default().preset_png().is_none());
    }

    #[test]
    fn preset_png_in_range_and_out_of_range() {
        // Every declared preset index resolves to embedded bytes.
        for i in 0..PRESET_COUNT {
            assert!(
                Avatar::Preset(i).preset_png().is_some(),
                "preset {i} missing"
            );
        }
        // Out-of-range index gracefully yields None (→ monogram fallback).
        assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
        assert!(Avatar::Preset(250).preset_png().is_none());
    }

    #[test]
    fn avatar_serde_round_trips_and_defaults() {
        // Round-trips through JSON (it rides presence + persists in config).
        for a in [Avatar::Monogram, Avatar::Preset(0), Avatar::Preset(5)] {
            let s = serde_json::to_string(&a).unwrap();
            assert_eq!(serde_json::from_str::<Avatar>(&s).unwrap(), a);
        }
    }

    /// 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_rgba8(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_upload_resizes_and_round_trips() {
        // A 400x200 image downscales so the longest side is CUSTOM_MAX_PX.
        let raw = make_png(400, 200);
        let avatar = process_upload(&raw).expect("should process");
        assert!(matches!(avatar, Avatar::Custom(_)));
        // The stored base64 decodes back to a PNG within bounds.
        let png = avatar.custom_png().expect("custom png decodes");
        let decoded = image::load_from_memory(&png).unwrap();
        assert_eq!(decoded.width().max(decoded.height()), CUSTOM_MAX_PX);
        assert!(decoded.width() <= CUSTOM_MAX_PX && decoded.height() <= CUSTOM_MAX_PX);
    }

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

    #[test]
    fn sanitize_incoming_passes_monogram_and_presets() {
        assert_eq!(Avatar::Monogram.sanitize_incoming(), Avatar::Monogram);
        assert_eq!(Avatar::Preset(2).sanitize_incoming(), Avatar::Preset(2));
    }

    #[test]
    fn sanitize_incoming_accepts_valid_custom() {
        let avatar = process_upload(&make_png(64, 64)).unwrap();
        assert_eq!(avatar.clone().sanitize_incoming(), avatar);
    }

    #[test]
    fn sanitize_incoming_rejects_junk_and_oversize() {
        // Not valid base64 / not a PNG → downgraded to monogram.
        assert_eq!(
            Avatar::Custom("not base64!!!".into()).sanitize_incoming(),
            Avatar::Monogram
        );
        // Over the byte cap → downgraded without even decoding.
        let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
        assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
    }

    #[test]
    fn dark_text_chosen_on_light_backgrounds() {
        assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text
        assert!(!use_dark_text_on((0x10, 0x10, 0x10))); // near-black bg → light text
    }
}
