//! Roster-bound chat identity (chat-hardening plan, Phase 2).
//!
//! The wire `GossipMessage::Chat` carries a sender-CLAIMED display name, which
//! any insider could set to another member's name. This map is the antidote:
//! the core event task records each authenticated member's latest sanitized
//! presence name here (from `PeerJoined`/`PeerUpdated`, the events that only
//! fire for a verified signed `Announce`), and chat renders under THAT name —
//! the embedded wire name is never displayed.
//!
//! Shared (`Arc<Mutex<…>>`) because eviction happens in two places: the event
//! task itself (graceful `PeerLeft`) and the detached reconnect-grace timer
//! (terminal eviction). A peer mid-reconnect-grace keeps its entry, so its
//! chat stays admitted until the grace actually expires.

use iroh::EndpointId;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

/// Bound on tracked names. Mirrors the gossip roster cap (`MAX_ACTIVE_PEERS`):
/// insertions only follow cap-gated roster admissions, so this is pure defense
/// in depth against that invariant breaking.
const CHAT_ROSTER_CAP: usize = 32;

/// The authoritative id → display-name map for the current room. Cheap to
/// clone; all clones share one map.
#[derive(Debug, Clone, Default)]
pub struct ChatRoster {
    names: Arc<Mutex<HashMap<EndpointId, String>>>,
}

impl ChatRoster {
    /// Record (or refresh) a member's display name. The name is re-sanitized
    /// here (idempotent — gossip ingress already did) and an empty result falls
    /// back to the short node id so a chat line is never label-less. A NEW id
    /// is refused past the cap; updates to a present id always land.
    pub fn upsert(&self, id: EndpointId, name: &str) {
        let clean = crate::sanitize::sanitize_name(name);
        let label = if clean.is_empty() {
            crate::short_id(&id.to_string())
        } else {
            clean
        };
        let mut names = self.names.lock().unwrap();
        if names.contains_key(&id) || names.len() < CHAT_ROSTER_CAP {
            names.insert(id, label);
        }
    }

    /// Drop a member on graceful leave or terminal (grace-expired) eviction.
    pub fn remove(&self, id: &EndpointId) {
        self.names.lock().unwrap().remove(id);
    }

    /// The roster-bound name for an id, or `None` if the author is not a
    /// current member — the caller must then drop the chat entirely.
    pub fn name_of(&self, id: &EndpointId) -> Option<String> {
        self.names.lock().unwrap().get(id).cloned()
    }
}

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

    fn fresh_id() -> EndpointId {
        SecretKey::generate().public()
    }

    #[test]
    fn upsert_then_lookup_returns_sanitized_name() {
        let roster = ChatRoster::default();
        let a = fresh_id();
        roster.upsert(a, "Alice");
        assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
        // Bidi override / zero-width spoofing characters are stripped.
        roster.upsert(a, "Al\u{202E}ice\u{200B}");
        assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
    }

    #[test]
    fn name_update_affects_future_lookups() {
        let roster = ChatRoster::default();
        let a = fresh_id();
        roster.upsert(a, "Alice");
        roster.upsert(a, "Alice2");
        assert_eq!(roster.name_of(&a), Some("Alice2".to_string()));
    }

    #[test]
    fn unknown_author_has_no_name() {
        let roster = ChatRoster::default();
        roster.upsert(fresh_id(), "Alice");
        assert_eq!(roster.name_of(&fresh_id()), None);
    }

    #[test]
    fn removed_author_is_no_longer_a_member() {
        let roster = ChatRoster::default();
        let a = fresh_id();
        roster.upsert(a, "Alice");
        roster.remove(&a);
        assert_eq!(roster.name_of(&a), None);
    }

    #[test]
    fn empty_sanitized_name_falls_back_to_short_id() {
        let roster = ChatRoster::default();
        let a = fresh_id();
        roster.upsert(a, "\u{0}\r\n\t ");
        let label = roster.name_of(&a).unwrap();
        assert!(!label.is_empty());
        assert_eq!(label, crate::short_id(&a.to_string()));
    }

    #[test]
    fn new_ids_are_refused_past_the_cap_but_updates_land() {
        let roster = ChatRoster::default();
        let first = fresh_id();
        roster.upsert(first, "member");
        for _ in 1..CHAT_ROSTER_CAP {
            roster.upsert(fresh_id(), "member");
        }
        // A brand-new 33rd id is refused...
        let overflow = fresh_id();
        roster.upsert(overflow, "overflow");
        assert_eq!(roster.name_of(&overflow), None);
        // ...but an update to a present id still lands at the cap.
        roster.upsert(first, "renamed");
        assert_eq!(roster.name_of(&first), Some("renamed".to_string()));
    }
}
