use crate::network::{NetError, PeerSpeakTicket, PeerState, RoomEvent, RoomState};
use async_trait::async_trait;
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
use iroh_gossip::net::Gossip;
use iroh_gossip::proto::TopicId;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver;
use tokio_stream::StreamExt;

/// Domain-separation tag mixed into every signed gossip payload so a signature
/// can never be lifted out of this protocol/version into another context.
use crate::protocol::GOSSIP_SIG_DOMAIN;

/// How far a payload's sender-stamped timestamp may differ from local time
/// before it's rejected as stale (replayed) or implausibly future. Bounds the
/// window in which a captured *genuine* signed payload could be replayed; within
/// it, a replay is byte-identical (the signature fixes the bytes) so the gossip
/// swarm's own in-flight de-duplication suppresses it. 2 minutes tolerates
/// reasonable cross-peer clock skew without leaving a wide replay window.
const GOSSIP_FRESHNESS_MS: u64 = 120_000;

/// Hard cap on an inbound gossip frame before it is deserialized. The largest
/// legitimate payload is an `Announce` carrying a full custom avatar (≤48 KB
/// base64, [`crate::avatar::CUSTOM_MAX_B64`]) plus the small presence/signature
/// fields — about 49 KB on the wire. This cap sits comfortably above that while
/// bounding the work/allocation a hostile peer can force: `serde_json::from_slice`
/// allocates while parsing, so post-deserialize string caps do NOT prevent abuse —
/// the size must be checked *before* parsing (security hardening, Codex find).
const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024;

/// A gossip message plus the authentication envelope that proves who sent it.
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
/// `author` can't validate (the attacker lacks the victim's secret key). `ts`
/// (sender-stamped unix-millis) is covered by the signature and gates replay
/// freshness — distinct from `GossipMessage::Chat.ts`, an unauthenticated
/// duplicate kept only for wire compatibility and ignored on receive.
#[derive(Serialize, Deserialize, Clone)]
pub struct GossipPayload {
    pub author: EndpointId,
    pub ts: u64,
    pub msg: GossipMessage,
    pub sig: Signature,
}

// `iroh::Signature` doesn't implement `Debug`, so derive everything else and
// hand-write a `Debug` that omits the (noisy, non-Debug) signature.
impl std::fmt::Debug for GossipPayload {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GossipPayload")
            .field("author", &self.author)
            .field("ts", &self.ts)
            .field("msg_kind", &gossip_message_kind(&self.msg))
            .finish_non_exhaustive()
    }
}

/// Current unix time in milliseconds (0 on the impossible pre-epoch error).
fn now_millis() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// The exact bytes an ed25519 gossip signature covers: domain tag + room topic +
/// claimed author + timestamp + message. Binding the **topic** prevents replay
/// into another room; binding the **timestamp** bounds temporal replay; binding
/// the **author** means a signature only validates for the key it claims to come
/// from. Deterministic (same inputs → same bytes on both ends).
fn signable_bytes(topic: &[u8; 32], author: &EndpointId, ts: u64, msg: &GossipMessage) -> Vec<u8> {
    serde_json::to_vec(&(GOSSIP_SIG_DOMAIN, topic, author, ts, msg)).unwrap_or_default()
}

/// Build a signed payload from our secret key for the given room topic.
fn sign_gossip(secret: &SecretKey, topic: &[u8; 32], ts: u64, msg: GossipMessage) -> GossipPayload {
    let author = secret.public();
    let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg));
    GossipPayload {
        author,
        ts,
        msg,
        sig,
    }
}

/// Why a received gossip payload was rejected (logging + tests).
#[derive(Debug, PartialEq, Eq)]
enum GossipReject {
    /// Signature didn't validate for the claimed `author` — a forgery/spoof.
    BadSignature,
    /// Timestamp outside the freshness window — stale (replay) or implausibly future.
    OutOfWindow,
    /// A signed Announce advertised an address for a different node id.
    AnnounceAddressMismatch,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum StateMutationKind {
    Announce,
    Leave,
}

fn gossip_message_kind(msg: &GossipMessage) -> &'static str {
    match msg {
        GossipMessage::Announce(_) => "Announce",
        GossipMessage::Leave => "Leave",
        GossipMessage::Chat { .. } => "Chat",
    }
}

fn state_mutation_kind(msg: &GossipMessage) -> Option<StateMutationKind> {
    match msg {
        GossipMessage::Announce(_) => Some(StateMutationKind::Announce),
        GossipMessage::Leave => Some(StateMutationKind::Leave),
        GossipMessage::Chat { .. } => None,
    }
}

fn admit_state_mutation(
    seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
    author: EndpointId,
    msg: &GossipMessage,
    ts: u64,
) -> bool {
    let Some(kind) = state_mutation_kind(msg) else {
        return true;
    };
    let key = (author, kind);
    if seen.get(&key).is_some_and(|last_ts| ts <= *last_ts) {
        return false;
    }
    seen.insert(key, ts);
    true
}

/// Size at which we prune stale entries from the replay-tracking map (Tier C
/// F-01 audit). `admit_state_mutation` records `(author, kind)` for every signed
/// mutation, so an insider sending validly signed `Leave`s from unlimited
/// generated keys would otherwise grow it for the room's lifetime. A mutation
/// older than the freshness window can never be the deciding `last_ts` for an
/// in-window message — `verify_gossip`'s timestamp check rejects such a replay
/// first — so dropping those entries cannot weaken replay protection; it bounds
/// the map to roughly the authors seen within one freshness window.
const STATE_MUTATIONS_SOFT_CAP: usize = 256;

/// Drop replay-tracking entries whose timestamp is older than `window_ms` before
/// `now_ms` (see [`STATE_MUTATIONS_SOFT_CAP`]). Pure → unit-testable.
fn prune_stale_mutations(
    seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
    now_ms: u64,
    window_ms: u64,
) {
    let floor = now_ms.saturating_sub(window_ms);
    seen.retain(|_, last_ts| *last_ts >= floor);
}

/// Three signed, out-of-window payloads inside one minute is enough to distinguish
/// a persistently skewed clock from a single delayed gossip frame without making
/// the user wait long. Repeats are suppressed for five minutes per author.
const CLOCK_SKEW_OBSERVATION_WINDOW_MS: u64 = 60_000;
const CLOCK_SKEW_WARNING_THRESHOLD: usize = 3;
const CLOCK_SKEW_COOLDOWN_MS: u64 = 5 * 60_000;
const CLOCK_SKEW_AUTHORS_SOFT_CAP: usize = 256;
const CLOCK_SKEW_AUTHORS_HARD_CAP: usize = 512;
const CLOCK_SKEW_AUTHOR_TTL_MS: u64 = CLOCK_SKEW_COOLDOWN_MS;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClockSkewWarning {
    author: EndpointId,
    /// Positive means the peer's sender-stamped clock is ahead of ours.
    skew_ms: i64,
}

#[derive(Debug, Default)]
struct ClockSkewMonitor {
    authors: HashMap<EndpointId, ClockSkewAuthorState>,
}

#[derive(Debug, Default)]
struct ClockSkewAuthorState {
    observed_at: Vec<u64>,
    last_seen_ms: u64,
    last_warned_ms: Option<u64>,
}

impl ClockSkewMonitor {
    fn observe(
        &mut self,
        author: EndpointId,
        skew_ms: i64,
        now_ms: u64,
    ) -> Option<ClockSkewWarning> {
        if self.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP {
            self.prune_stale_authors(now_ms);
        }

        let warning = {
            let state = self.authors.entry(author).or_default();
            state.last_seen_ms = now_ms;
            let floor = now_ms.saturating_sub(CLOCK_SKEW_OBSERVATION_WINDOW_MS);
            state.observed_at.retain(|ts| *ts >= floor);
            state.observed_at.push(now_ms);
            if state.observed_at.len() > CLOCK_SKEW_WARNING_THRESHOLD {
                let excess = state.observed_at.len() - CLOCK_SKEW_WARNING_THRESHOLD;
                state.observed_at.drain(0..excess);
            }

            let threshold_met = state.observed_at.len() >= CLOCK_SKEW_WARNING_THRESHOLD;
            let in_cooldown = state
                .last_warned_ms
                .is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
            if threshold_met && !in_cooldown {
                state.last_warned_ms = Some(now_ms);
                Some(ClockSkewWarning { author, skew_ms })
            } else {
                None
            }
        };

        if self.authors.len() > CLOCK_SKEW_AUTHORS_HARD_CAP {
            self.drop_oldest_authors();
        }

        warning
    }

    fn prune_stale_authors(&mut self, now_ms: u64) {
        let stale_before = now_ms.saturating_sub(CLOCK_SKEW_AUTHOR_TTL_MS);
        self.authors.retain(|_, state| {
            let last_warning_live = state
                .last_warned_ms
                .is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
            last_warning_live || state.last_seen_ms >= stale_before
        });
    }

    fn drop_oldest_authors(&mut self) {
        let remove_count = self
            .authors
            .len()
            .saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
        let mut by_age: Vec<_> = self
            .authors
            .iter()
            .map(|(author, state)| (*author, state.last_seen_ms))
            .collect();
        by_age.sort_by_key(|(_, last_seen_ms)| *last_seen_ms);
        for (author, _) in by_age.into_iter().take(remove_count) {
            self.authors.remove(&author);
        }
    }
}

/// Chat-hardening Phase 2 policy (docs/chat-hardening-plan.md): exact-replay
/// suppression bounds and the token-bucket rates that stop one admitted member
/// from monopolizing the event channel / UI with chat.
///
/// The replay cache is keyed on the payload's Ed25519 SIGNATURE bytes rather
/// than a separate BLAKE3 digest: ed25519 signing is deterministic (RFC 8032),
/// so the 64-byte signature is itself a collision-resistant fingerprint of the
/// exact signed bytes (topic + author + ts + msg) — same dedup power, zero new
/// dependencies. Entries are stamped with the payload's SIGNED timestamp and
/// pruned once that falls out of the freshness window, because `verify_gossip`
/// already rejects any replay whose signed `ts` is out-of-window — an expired
/// cache entry can no longer correspond to an admissible frame.
const CHAT_REPLAY_CACHE_CAP: usize = 1024;

/// Per-author chat budget: a burst of 8 absorbs a fast typist; 1 msg/s sustained
/// is well above real human chat rate while bounding a flooder to a trickle.
/// `pub(crate)` because the sender-side pacer (chat-hardening Phase 5) mirrors
/// this exact policy — one definition, so the two sides can never drift apart.
pub(crate) const CHAT_AUTHOR_BURST: f64 = 8.0;
pub(crate) const CHAT_AUTHOR_REFILL_PER_MS: f64 = 1.0 / 1000.0;

/// Room-wide chat budget across ALL authors, so a set of sock-puppet identities
/// can't multiply the per-author budget into unbounded event-channel pressure.
const CHAT_ROOM_BURST: f64 = 32.0;
const CHAT_ROOM_REFILL_PER_MS: f64 = 8.0 / 1000.0;

/// Bound on the per-author bucket map. Authors only enter it after the
/// known-author gate, so it tracks roughly the live roster plus recently
/// disconnected members; idle entries are pruned past this cap.
const CHAT_AUTHOR_BUCKETS_CAP: usize = 64;

/// Cooldown between logged chat rejections for one author (and one shared slot
/// for unknown authors), so a flood of rejected frames can't turn the log into
/// the new unbounded cost.
const CHAT_REJECT_LOG_COOLDOWN_MS: u64 = 10_000;

/// A minimal deterministic token bucket: time is passed in, never read from a
/// clock, so every boundary is unit-testable. Shared with the sender-side chat
/// pacer (`app::sendqueue`) so both sides of the rate policy use one mechanism.
#[derive(Debug, Clone, Copy)]
pub(crate) struct TokenBucket {
    tokens: f64,
    last_ms: u64,
}

impl TokenBucket {
    pub(crate) fn full(burst: f64, now_ms: u64) -> Self {
        Self {
            tokens: burst,
            last_ms: now_ms,
        }
    }

    /// Refill for elapsed time (capped at `burst`), then take one token if
    /// available. Returns whether a token was consumed.
    pub(crate) fn try_take(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) -> bool {
        let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
        self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
        self.last_ms = now_ms;
        if self.tokens >= 1.0 {
            self.tokens -= 1.0;
            true
        } else {
            false
        }
    }
}

/// Why an authenticated chat payload was still refused admission (Phase 2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChatReject {
    /// Author is neither a live gossip peer nor one mid-reconnect. The core
    /// roster gate re-checks this as the final authority; this early copy just
    /// refuses the work before any sanitize/attachment handling.
    UnknownAuthor,
    /// Exact byte-for-byte replay of an already-admitted signed chat.
    Replay,
    /// Per-author or room-wide token bucket empty.
    RateLimited,
}

/// Per-author rate-limit + log-squelch state (see [`ChatIngressGate`]).
#[derive(Debug)]
struct AuthorGateState {
    bucket: TokenBucket,
    last_seen_ms: u64,
    last_reject_log_ms: Option<u64>,
}

/// Chat admission gate run after `verify_gossip`, before any sanitize work or
/// event-channel send (chat-hardening plan Phase 2): known author → exact-replay
/// dedup → per-author + room token buckets, in that order. Dedup runs BEFORE the
/// buckets so a replayed frame can never consume tokens and starve the author's
/// own legitimate next message. Pure — callers pass `now_ms` — so every branch
/// is unit-testable.
#[derive(Debug)]
struct ChatIngressGate {
    seen: HashSet<[u8; 64]>,
    /// FIFO of (signature, signed ts) mirroring `seen`, for TTL + cap pruning.
    seen_order: std::collections::VecDeque<([u8; 64], u64)>,
    room: TokenBucket,
    authors: HashMap<EndpointId, AuthorGateState>,
    /// Shared squelch slot for unknown-author rejects (they have no map entry).
    last_unknown_log_ms: Option<u64>,
}

impl ChatIngressGate {
    fn new(now_ms: u64) -> Self {
        Self {
            seen: HashSet::new(),
            seen_order: std::collections::VecDeque::new(),
            room: TokenBucket::full(CHAT_ROOM_BURST, now_ms),
            authors: HashMap::new(),
            last_unknown_log_ms: None,
        }
    }

    /// Admit or reject one verified chat payload. `known_author` is the caller's
    /// live-or-reconnecting membership check; `payload_ts` is the SIGNED envelope
    /// timestamp (already freshness-checked by `verify_gossip`).
    fn admit(
        &mut self,
        known_author: bool,
        author: EndpointId,
        sig: &[u8; 64],
        payload_ts: u64,
        now_ms: u64,
    ) -> Result<(), ChatReject> {
        if !known_author {
            return Err(ChatReject::UnknownAuthor);
        }
        self.prune_replay_cache(now_ms);
        if self.seen.contains(sig) {
            return Err(ChatReject::Replay);
        }
        // Room bucket first: it is the cheaper aggregate bound, and consuming
        // from it only when the author bucket also admits keeps the two in
        // lockstep — so check both, then commit both.
        let author_state = self.author_entry(author, now_ms);
        let author_ok =
            author_state
                .bucket
                .try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms);
        if !author_ok {
            return Err(ChatReject::RateLimited);
        }
        if !self
            .room
            .try_take(CHAT_ROOM_BURST, CHAT_ROOM_REFILL_PER_MS, now_ms)
        {
            // Refund the author token so a room-wide squeeze doesn't also debit
            // every individual author's future budget.
            if let Some(state) = self.authors.get_mut(&author) {
                state.bucket.tokens = (state.bucket.tokens + 1.0).min(CHAT_AUTHOR_BURST);
            }
            return Err(ChatReject::RateLimited);
        }
        // Fully admitted — only now does the frame enter the replay cache, so a
        // rate-limited legitimate message redelivered later is not misread as a
        // replay of something that was never displayed.
        self.seen.insert(*sig);
        self.seen_order.push_back((*sig, payload_ts));
        Ok(())
    }

    /// Whether this rejection should be logged: at most one log line per author
    /// (or one shared line for unknown authors) per cooldown window.
    fn should_log_reject(&mut self, known_author: bool, author: EndpointId, now_ms: u64) -> bool {
        let slot = if known_author {
            self.authors
                .get_mut(&author)
                .map(|state| &mut state.last_reject_log_ms)
        } else {
            Some(&mut self.last_unknown_log_ms)
        };
        let Some(slot) = slot else {
            return true;
        };
        let due =
            slot.is_none_or(|last| now_ms.saturating_sub(last) >= CHAT_REJECT_LOG_COOLDOWN_MS);
        if due {
            *slot = Some(now_ms);
        }
        due
    }

    /// Drop cache entries whose signed timestamp fell out of the freshness
    /// window (they can no longer pass `verify_gossip`), then enforce the hard
    /// cap FIFO-oldest-first.
    fn prune_replay_cache(&mut self, now_ms: u64) {
        let floor = now_ms.saturating_sub(GOSSIP_FRESHNESS_MS);
        while let Some((sig, ts)) = self.seen_order.front() {
            if *ts >= floor && self.seen_order.len() < CHAT_REPLAY_CACHE_CAP {
                break;
            }
            self.seen.remove(sig);
            self.seen_order.pop_front();
        }
    }

    /// Get-or-create the author's bucket state, pruning the map if a flood of
    /// short-lived identities has grown it past its cap: idle authors (nothing
    /// admitted within the freshness window) go first, then oldest-seen.
    fn author_entry(&mut self, author: EndpointId, now_ms: u64) -> &mut AuthorGateState {
        if !self.authors.contains_key(&author) && self.authors.len() >= CHAT_AUTHOR_BUCKETS_CAP {
            let floor = now_ms.saturating_sub(GOSSIP_FRESHNESS_MS);
            self.authors.retain(|_, state| state.last_seen_ms >= floor);
            while self.authors.len() >= CHAT_AUTHOR_BUCKETS_CAP {
                if let Some(oldest) = self
                    .authors
                    .iter()
                    .min_by_key(|(_, state)| state.last_seen_ms)
                    .map(|(id, _)| *id)
                {
                    self.authors.remove(&oldest);
                } else {
                    break;
                }
            }
        }
        let state = self.authors.entry(author).or_insert(AuthorGateState {
            bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms),
            last_seen_ms: now_ms,
            last_reject_log_ms: None,
        });
        state.last_seen_ms = now_ms;
        state
    }

    /// Drop an author's limiter state alongside its roster eviction (a signed
    /// `Leave`), so the map stays bounded by the roster's own churn.
    fn evict_author(&mut self, author: &EndpointId) {
        self.authors.remove(author);
    }
}

/// Maximum number of distinct peers we hold in a room roster at once.
///
/// Everyone with the room ticket is an authenticated *insider*: a signature only
/// proves ownership of the generated keypair it was made with, not that the
/// author is a distinct human. A malicious member can therefore mint many valid
/// signed identities. Voice is full-mesh (each peer dials every other), so a real
/// room is realistically well under this bound; the cap exists purely so a flood
/// of sock-puppet `Announce`s can't grow our peer map / audio supervisors / dials
/// without limit (Tier C F-01).
const MAX_ACTIVE_PEERS: usize = 32;

/// Maximum transport addresses we retain from a single peer announce. iroh
/// normally advertises a handful (a few LAN/WAN IP candidates plus one home
/// relay); the cap stops an insider stuffing a large unique address set into each
/// announce to inflate the address lookup and the dialer's candidate list.
const MAX_PEER_ADDRS: usize = 8;

/// Maximum byte length of a relay URL we accept inside a peer address. A relay
/// URL is normal-length; anything longer is dropped rather than retained.
const MAX_RELAY_URL_LEN: usize = 256;

/// Bound an untrusted peer's advertised address set before we retain it / hand it
/// to the address lookup and dialer (Tier C F-01). Drops transport kinds we never
/// use (`Custom`) and over-long relay URLs, then truncates to at most
/// [`MAX_PEER_ADDRS`] addresses. `BTreeSet` iteration is deterministic, so the
/// kept subset is stable. Pure → unit-testable.
fn sanitize_endpoint_addr(addr: &EndpointAddr) -> EndpointAddr {
    let addrs: BTreeSet<TransportAddr> = addr
        .addrs
        .iter()
        .filter(|a| match a {
            TransportAddr::Relay(url) => url.as_str().len() <= MAX_RELAY_URL_LEN,
            TransportAddr::Ip(_) => true,
            // `TransportAddr` is #[non_exhaustive]; we only speak IP + relay, so
            // anything else (Custom / future kinds) is dropped, not retained.
            _ => false,
        })
        .take(MAX_PEER_ADDRS)
        .cloned()
        .collect();
    EndpointAddr { id: addr.id, addrs }
}

/// Whether an `Announce` may enter the roster. Only a brand-new author
/// (`subject_to_cap`) is gated by [`MAX_ACTIVE_PEERS`]; updates to an
/// already-present peer AND re-announces from a peer mid-reconnect (which
/// already held a slot) always pass — exempting reconnects keeps a full room
/// from rejecting a legitimately reconnecting member and orphaning its recovery
/// state (Tier C F-01 audit). Pure → unit-testable.
fn admit_into_roster(roster_len: usize, subject_to_cap: bool, max_peers: usize) -> bool {
    !subject_to_cap || roster_len < max_peers
}

/// Whether a received `Announce`'s author is gated by the roster cap. A peer
/// already in the roster (`is_new == false`, an ordinary update) or one
/// mid-reconnect (`is_reconnecting`, it already held a slot) is exempt; only a
/// brand-new author counts against [`MAX_ACTIVE_PEERS`] (Tier C F-01 audit).
/// Pure → unit-testable.
fn announce_subject_to_cap(is_new: bool, is_reconnecting: bool) -> bool {
    is_new && !is_reconnecting
}

fn peer_state_for_log(state: &PeerState) -> String {
    format!(
        "name={:?}, muted={}, addr_id={}, addrs={}, sharing={}, game={:?}",
        state.name,
        state.is_muted,
        crate::short_id(&state.addr.id.to_string()),
        state.addr.addrs.len(),
        state.sharing.is_some(),
        state.game
    )
}

/// Authenticate a received payload against the room topic and local clock. The
/// signature must validate for the claimed `author` (closing the author-spoofing
/// vector, security S2), and the timestamp must be within `window_ms` of
/// `now_ms`. Pure — no I/O — so it's directly unit-testable.
fn verify_gossip(
    payload: &GossipPayload,
    topic: &[u8; 32],
    now_ms: u64,
    window_ms: u64,
) -> Result<(), GossipReject> {
    let bytes = signable_bytes(topic, &payload.author, payload.ts, &payload.msg);
    payload
        .author
        .verify(&bytes, &payload.sig)
        .map_err(|_| GossipReject::BadSignature)?;
    if now_ms.abs_diff(payload.ts) > window_ms {
        return Err(GossipReject::OutOfWindow);
    }
    if let GossipMessage::Announce(state) = &payload.msg
        && state.addr.id != payload.author
    {
        return Err(GossipReject::AnnounceAddressMismatch);
    }
    Ok(())
}

/// Compute the gossip bootstrap peer set for a (re)join: the ticket's host plus
/// every peer we already knew about (`extra`), with self removed and ids
/// de-duplicated. Without the retained `extra` peers, the ROOM CREATOR rejoining
/// their own room would have an empty list (their ticket names only themselves as
/// host) and so never re-enter the swarm — that was bug A8. Pure → unit-testable.
fn compute_bootstrap(
    self_id: EndpointId,
    ticket_host: EndpointId,
    extra: &[EndpointAddr],
) -> Vec<EndpointId> {
    let mut out: Vec<EndpointId> = Vec::new();
    if ticket_host != self_id {
        out.push(ticket_host);
    }
    for addr in extra {
        if addr.id != self_id && !out.contains(&addr.id) {
            out.push(addr.id);
        }
    }
    out
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum GossipMessage {
    Announce(PeerState),
    Leave,
    /// A room text-chat message: the author's display name, the text, a
    /// sender-stamped millisecond timestamp, and an optional file attachment
    /// descriptor (the bytes are fetched off-gossip on the file plane).
    Chat {
        name: String,
        text: String,
        ts: u64,
        #[serde(default)]
        attachment: Option<crate::files::ChatAttachment>,
    },
}

pub struct IrohGossipState {
    _endpoint: Endpoint,
    gossip: Gossip,
    address_lookup: iroh::address_lookup::memory::MemoryLookup,
    /// Our node secret key, used to sign every outgoing gossip payload so peers
    /// can authenticate us (security S2). Same key the endpoint binds with.
    secret_key: SecretKey,
    self_state: Arc<Mutex<Option<PeerState>>>,
    peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
    /// Previously verified peers whose live roster entry was removed by a
    /// transient disconnect. Retained only so a later authenticated `Leave`
    /// still reaches core and cancels background recovery.
    disconnected_peers: Arc<Mutex<HashSet<EndpointId>>>,
    event_tx: mpsc::Sender<RoomEvent>,
    event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
    active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
    active_topic_id: Mutex<Option<TopicId>>,
    /// Raw 32-byte id of the joined topic, retained so the out-of-task signers
    /// (`update_self_state`/`send_chat`/`leave`) can bind signatures to the room.
    active_topic_bytes: Mutex<Option<[u8; 32]>>,
    active_sender: Mutex<Option<iroh_gossip::api::GossipSender>>,
}

impl IrohGossipState {
    pub fn new(
        endpoint: Endpoint,
        gossip: Gossip,
        address_lookup: iroh::address_lookup::memory::MemoryLookup,
        secret_key: SecretKey,
    ) -> Self {
        let (event_tx, event_rx) = mpsc::channel(100);
        Self {
            _endpoint: endpoint,
            gossip,
            address_lookup,
            secret_key,
            self_state: Arc::new(Mutex::new(None)),
            peers: Arc::new(Mutex::new(HashMap::new())),
            disconnected_peers: Arc::new(Mutex::new(HashSet::new())),
            event_tx,
            event_rx: Mutex::new(Some(event_rx)),
            active_topic: Mutex::new(None),
            active_topic_id: Mutex::new(None),
            active_topic_bytes: Mutex::new(None),
            active_sender: Mutex::new(None),
        }
    }
}

#[async_trait]
impl RoomState for IrohGossipState {
    async fn join(
        &self,
        ticket_str: &str,
        self_state: PeerState,
        extra_bootstrap: Vec<EndpointAddr>,
    ) -> Result<(), NetError> {
        crate::log_msg(&format!(
            "RoomState::join: self_id={}, self_name={:?}, ticket={}",
            crate::short_id(&self_state.addr.id.to_string()),
            self_state.name,
            crate::redact_for_log(ticket_str)
        ));
        let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
        // Version-namespace the subscribed topic (VERSIONING.md): peers on a
        // different gossip protocol version derive a different topic from the same
        // ticket and never share a swarm. The raw ticket.topic_id stays the room
        // identity (and what signatures bind, below).
        let topic_id = TopicId::from_bytes(crate::protocol::versioned_topic(ticket.topic_id));

        crate::log_msg(&format!(
            "Parsed ticket. host_id={}, host_addrs={}, topic={}",
            crate::short_id(&ticket.host_addr.id.to_string()),
            ticket.host_addr.addrs.len(),
            crate::short_bytes_hex(&ticket.topic_id)
        ));

        // Stop any currently running topic
        let _ = self.leave().await;

        // Make every dial target resolvable: the ticket host plus any retained
        // peers handed in (their addresses may have aged out of a fresh endpoint's
        // book even though the persistent lookup usually still holds them).
        self.address_lookup
            .add_endpoint_info(ticket.host_addr.clone());
        for addr in &extra_bootstrap {
            self.address_lookup.add_endpoint_info(addr.clone());
        }

        // Bootstrap to the ticket host AND every retained peer (minus self). The
        // retained peers are what let the room creator rejoin a room they left —
        // their own ticket names only themselves as host (bug A8).
        let bootstrap_peers =
            compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap);
        crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers));

        let gossip_topic = self
            .gossip
            .subscribe(topic_id, bootstrap_peers)
            .await
            .map_err(|e| {
                let err = format!("Failed to join gossip topic: {}", e);
                crate::log_msg(&err);
                NetError::Gossip(err)
            })?;

        let (gossip_sender, mut gossip_receiver) = gossip_topic.split();

        *self.self_state.lock().unwrap() = Some(self_state.clone());
        *self.active_topic_id.lock().unwrap() = Some(topic_id);
        *self.active_topic_bytes.lock().unwrap() = Some(ticket.topic_id);
        *self.active_sender.lock().unwrap() = Some(gossip_sender.clone());

        let event_tx = self.event_tx.clone();
        let peers = self.peers.clone();
        let disconnected_peers = self.disconnected_peers.clone();
        let address_lookup = self.address_lookup.clone();
        let self_state_clone = self.self_state.clone();
        let gossip_sender_clone = gossip_sender.clone();
        let self_id = self_state.addr.id;
        // The signing key + room topic the spawned loop needs to sign our own
        // broadcasts and authenticate every inbound payload (security S2).
        let secret_key = self.secret_key.clone();
        let topic_bytes = ticket.topic_id;

        let handle = tokio::spawn(async move {
            crate::log_msg(&format!(
                "Spawned gossip topic loop for self_id={:?}",
                self_id
            ));
            let mut state_mutations_seen = HashMap::new();
            let mut clock_skew_monitor = ClockSkewMonitor::default();
            let mut chat_gate = ChatIngressGate::new(now_millis());

            // Broadcast initial state
            let initial_payload = {
                let guard = self_state_clone.lock().unwrap();
                guard.as_ref().map(|s| {
                    sign_gossip(
                        &secret_key,
                        &topic_bytes,
                        now_millis(),
                        GossipMessage::Announce(s.clone()),
                    )
                })
            };

            if let Some(payload) = initial_payload
                && let Ok(bytes) = serde_json::to_vec(&payload)
            {
                crate::log_msg(&format!(
                    "Broadcasting initial state from self_id={:?}",
                    self_id
                ));
                let _ = gossip_sender_clone.broadcast(bytes.into()).await;
            }

            // Stream topic messages
            while let Some(res) = gossip_receiver.next().await {
                match res {
                    Ok(iroh_gossip::api::Event::Received(msg)) => {
                        crate::log_msg(&format!(
                            "Gossip received Event::Received from delivery={:?}",
                            msg.delivered_from
                        ));
                        // Reject oversized frames BEFORE deserializing: parsing
                        // allocates, so a size check has to precede `from_slice` to
                        // bound the memory a hostile peer can make us hold.
                        if msg.content.len() > MAX_GOSSIP_FRAME_BYTES {
                            crate::log_msg(&format!(
                                "Gossip dropped oversized frame: {} bytes > {} cap",
                                msg.content.len(),
                                MAX_GOSSIP_FRAME_BYTES
                            ));
                            continue;
                        }
                        match serde_json::from_slice::<GossipPayload>(&msg.content) {
                            Ok(payload) => {
                                // Authenticate before trusting `author` for ANY
                                // action: a forged/stale payload is dropped here
                                // so it can't impersonate, evict, or poison
                                // presence/address-book (security S2).
                                let received_now_ms = now_millis();
                                if let Err(reason) = verify_gossip(
                                    &payload,
                                    &topic_bytes,
                                    received_now_ms,
                                    GOSSIP_FRESHNESS_MS,
                                ) {
                                    if reason == GossipReject::OutOfWindow {
                                        let skew_ms = payload.ts as i64 - received_now_ms as i64;
                                        if let Some(warning) = clock_skew_monitor.observe(
                                            payload.author,
                                            skew_ms,
                                            received_now_ms,
                                        ) {
                                            let _ = event_tx
                                                .send(RoomEvent::ClockSkewSuspected {
                                                    author: warning.author,
                                                    skew_ms: warning.skew_ms,
                                                })
                                                .await;
                                        }
                                    }
                                    crate::log_msg(&format!(
                                        "Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}",
                                        payload.author, reason
                                    ));
                                    continue;
                                }
                                let our_id = {
                                    self_state_clone.lock().unwrap().as_ref().map(|s| s.addr.id)
                                };

                                if Some(payload.author) == our_id {
                                    crate::log_msg(
                                        "Gossip Event::Received from ourselves; ignoring",
                                    );
                                    continue;
                                }

                                // Keep the replay-tracking map bounded: prune entries
                                // older than the freshness window once it grows past the
                                // soft cap (Tier C F-01 audit). Stale entries can't gate
                                // an in-window message, so this never weakens replay
                                // protection.
                                if state_mutations_seen.len() > STATE_MUTATIONS_SOFT_CAP {
                                    prune_stale_mutations(
                                        &mut state_mutations_seen,
                                        now_millis(),
                                        GOSSIP_FRESHNESS_MS,
                                    );
                                }
                                if !admit_state_mutation(
                                    &mut state_mutations_seen,
                                    payload.author,
                                    &payload.msg,
                                    payload.ts,
                                ) {
                                    crate::log_msg(&format!(
                                        "Gossip dropped replayed state mutation author={}, kind={}, ts={}",
                                        crate::short_id(&payload.author.to_string()),
                                        gossip_message_kind(&payload.msg),
                                        payload.ts
                                    ));
                                    continue;
                                }

                                crate::log_msg(&format!(
                                    "Gossip Event::Received author={}, kind={}",
                                    crate::short_id(&payload.author.to_string()),
                                    gossip_message_kind(&payload.msg)
                                ));

                                match payload.msg {
                                    GossipMessage::Announce(mut state) => {
                                        // Presence names are untrusted (and author-
                                        // spoofable): sanitize at ingest so every
                                        // consumer gets a safe value (security S4).
                                        state.name = crate::sanitize::sanitize_name(&state.name);
                                        // A peer's avatar is equally untrusted: a
                                        // custom image is validated (size + safe
                                        // decode within bounds) or downgraded to a
                                        // monogram, so a malformed/oversized/bomb
                                        // image can't crash or exhaust us (W4).
                                        state.avatar = state.avatar.sanitize_incoming();
                                        // Screen-share tickets are capabilities and
                                        // peer-supplied: cap/validate once at ingest
                                        // so invalid offers never render a Watch button.
                                        state.sharing = state
                                            .sharing
                                            .and_then(crate::screenshare::sanitize_ticket);
                                        // The game-presence label is untrusted
                                        // peer text like the name: sanitize +
                                        // length-cap at ingest (strip bidi/control,
                                        // 64-char/256-byte cap). An empty result
                                        // means "no game" rather than a blank label.
                                        state.game = state.game.and_then(|g| {
                                            let cleaned = crate::sanitize::sanitize_game_label(&g);
                                            (!cleaned.is_empty()).then_some(cleaned)
                                        });
                                        // Music presence is untrusted peer data:
                                        // the track name is display text (sanitize
                                        // + cap like the game label) and the size
                                        // bounds a future fetch (reject anything
                                        // outside the attachment cap).
                                        state.music = state.music.and_then(|mut m| {
                                            let name =
                                                crate::sanitize::sanitize_game_label(&m.name);
                                            if name.is_empty()
                                                || !crate::files::size_within_cap(m.size)
                                            {
                                                return None;
                                            }
                                            m.name = name;
                                            if m.next_id.is_some() {
                                                let ok = m
                                                    .next_size
                                                    .map(crate::files::size_within_cap)
                                                    .unwrap_or(false);
                                                if !ok {
                                                    m.next_id = None;
                                                    m.next_size = None;
                                                }
                                            }
                                            Some(m)
                                        });
                                        // Bound an insider's advertised address set
                                        // before we retain it / hand it to the dialer
                                        // (Tier C F-01).
                                        state.addr = sanitize_endpoint_addr(&state.addr);
                                        // A peer reconnecting from a transient drop sits
                                        // in `disconnected_peers` (not the live roster);
                                        // it already held a slot, so it must be re-admitted
                                        // regardless of the cap, and its disconnect marker
                                        // cleared ONLY once re-admitted — clearing it before
                                        // a possible reject would orphan its recovery state
                                        // (Tier C F-01 audit).
                                        let is_reconnecting = disconnected_peers
                                            .lock()
                                            .unwrap()
                                            .contains(&payload.author);
                                        let admitted = {
                                            let mut peer_map = peers.lock().unwrap();
                                            let is_new = !peer_map.contains_key(&payload.author);
                                            // Cap the roster so a flood of signed
                                            // sock-puppet identities can't grow our
                                            // memory/tasks/dials without bound (Tier C
                                            // F-01). Existing-peer updates and reconnects
                                            // are exempt; only brand-new authors are gated.
                                            let subject_to_cap =
                                                announce_subject_to_cap(is_new, is_reconnecting);
                                            if !admit_into_roster(
                                                peer_map.len(),
                                                subject_to_cap,
                                                MAX_ACTIVE_PEERS,
                                            ) {
                                                None
                                            } else {
                                                let state_changed =
                                                    peer_map.get(&payload.author) != Some(&state);
                                                if is_new || state_changed {
                                                    peer_map.insert(payload.author, state.clone());
                                                }
                                                Some((is_new, state_changed))
                                            }
                                        };
                                        let Some((is_new, state_changed)) = admitted else {
                                            crate::log_msg(&format!(
                                                "Gossip roster full ({MAX_ACTIVE_PEERS}); rejecting new peer {}",
                                                crate::short_id(&payload.author.to_string())
                                            ));
                                            continue;
                                        };
                                        // Admitted — now it is safe to clear any reconnect
                                        // marker (a rejected announce above leaves it intact
                                        // so a later signed Leave still cleans up).
                                        disconnected_peers.lock().unwrap().remove(&payload.author);

                                        if is_new {
                                            crate::log_msg(&format!(
                                                "Gossip new peer joined: {}, state: {}",
                                                crate::short_id(&payload.author.to_string()),
                                                peer_state_for_log(&state)
                                            ));
                                            // Replace (not union) the lookup's record for
                                            // this id with the authenticated, sanitized
                                            // address set, so leave/re-announce cycles
                                            // can't accumulate attacker-supplied history
                                            // (Tier C F-01).
                                            let _ = address_lookup
                                                .set_endpoint_info(state.addr.clone());
                                            let _ = event_tx
                                                .send(RoomEvent::PeerJoined(payload.author, state))
                                                .await;
                                        } else if state_changed {
                                            crate::log_msg(&format!(
                                                "Gossip peer state updated: {}, state: {}",
                                                crate::short_id(&payload.author.to_string()),
                                                peer_state_for_log(&state)
                                            ));
                                            let _ = event_tx
                                                .send(RoomEvent::PeerUpdated(payload.author, state))
                                                .await;
                                        }
                                    }
                                    GossipMessage::Leave => {
                                        crate::log_msg(&format!(
                                            "Gossip peer leave request from author={:?}",
                                            payload.author
                                        ));
                                        // Drop this id's address-lookup entry so cycling
                                        // distinct identities through Announce→Leave can't
                                        // grow the lookup for the room's lifetime (Tier C
                                        // F-01 audit). Re-announce re-populates it.
                                        let _ = address_lookup.remove_endpoint_info(payload.author);
                                        let removed =
                                            peers.lock().unwrap().remove(&payload.author).is_some();
                                        let was_disconnected = disconnected_peers
                                            .lock()
                                            .unwrap()
                                            .remove(&payload.author);
                                        // Roster eviction also drops the author's
                                        // chat-limiter state, keeping that map
                                        // bounded by roster churn (Phase 2).
                                        chat_gate.evict_author(&payload.author);
                                        if removed || was_disconnected {
                                            let _ = event_tx
                                                .send(RoomEvent::PeerLeft(payload.author))
                                                .await;
                                        }
                                    }
                                    GossipMessage::Chat {
                                        name,
                                        text,
                                        // The inner ts is an unauthenticated duplicate of the
                                        // signed envelope ts — ignored entirely; the envelope
                                        // value is what RoomEvent carries (Phase 2).
                                        ts: _,
                                        attachment,
                                    } => {
                                        // Phase 2 ingress admission, BEFORE any sanitize or
                                        // attachment work: known author (live or
                                        // mid-reconnect — the core roster gate is the final
                                        // authority) → exact-replay dedup keyed on the
                                        // signature → per-author + room token buckets.
                                        let known_author =
                                            peers.lock().unwrap().contains_key(&payload.author)
                                                || disconnected_peers
                                                    .lock()
                                                    .unwrap()
                                                    .contains(&payload.author);
                                        if let Err(reject) = chat_gate.admit(
                                            known_author,
                                            payload.author,
                                            &payload.sig.to_bytes(),
                                            payload.ts,
                                            received_now_ms,
                                        ) {
                                            if chat_gate.should_log_reject(
                                                known_author,
                                                payload.author,
                                                received_now_ms,
                                            ) {
                                                crate::log_msg(&format!(
                                                    "Dropped chat from author={}: {:?}",
                                                    crate::short_id(&payload.author.to_string()),
                                                    reject
                                                ));
                                            }
                                            continue;
                                        }
                                        crate::log_msg(&format!(
                                            "Gossip chat from author={:?}",
                                            payload.author
                                        ));
                                        // Defensively normalize an untrusted attachment
                                        // descriptor: sanitize the filename and drop it
                                        // entirely if it declares an out-of-cap size.
                                        let attachment = attachment.and_then(|mut a| {
                                            if !crate::files::size_within_cap(a.size) {
                                                return None;
                                            }
                                            a.name = crate::files::sanitize_filename(&a.name);
                                            Some(a)
                                        });
                                        // Chat text policy at INGRESS: reject raw text
                                        // over the byte ceiling before spending any
                                        // sanitize work on it (a compliant sender
                                        // sanitizes before signing), and drop a message
                                        // with neither visible text nor an attachment.
                                        let Some(text) = crate::sanitize::admit_chat_text(
                                            &text,
                                            attachment.is_some(),
                                        ) else {
                                            crate::log_msg(&format!(
                                                "Dropped out-of-policy chat from author={:?} (oversized or empty)",
                                                payload.author
                                            ));
                                            continue;
                                        };
                                        let _ = event_tx
                                            .send(RoomEvent::ChatMessage {
                                                from: payload.author,
                                                name,
                                                text,
                                                // Only the SIGNED envelope timestamp travels
                                                // downstream (never used for replay/ordering —
                                                // the gate above already handled replay).
                                                ts: payload.ts,
                                                attachment,
                                            })
                                            .await;
                                    }
                                }
                            }
                            Err(e) => {
                                crate::log_msg(&format!(
                                    "Gossip failed to deserialize payload: {:?}",
                                    e
                                ));
                            }
                        }
                    }
                    Ok(iroh_gossip::api::Event::NeighborUp(peer_id)) => {
                        crate::log_msg(&format!("Gossip event: NeighborUp={:?}", peer_id));
                        // Resend state on new neighbor connection to guarantee synchronization
                        let payload_opt = {
                            let guard = self_state_clone.lock().unwrap();
                            guard.as_ref().map(|state| {
                                sign_gossip(
                                    &secret_key,
                                    &topic_bytes,
                                    now_millis(),
                                    GossipMessage::Announce(state.clone()),
                                )
                            })
                        };
                        if let Some(payload) = payload_opt
                            && let Ok(bytes) = serde_json::to_vec(&payload)
                        {
                            crate::log_msg(&format!(
                                "Broadcasting state to new neighbor={:?}",
                                peer_id
                            ));
                            let _ = gossip_sender_clone.broadcast(bytes.into()).await;
                        }
                    }
                    Ok(iroh_gossip::api::Event::NeighborDown(peer_id)) => {
                        crate::log_msg(&format!("Gossip event: NeighborDown={:?}", peer_id));
                        // A NeighborDown is a *transient* loss, not a graceful
                        // leave: emit PeerConnectionLost so the core marks the peer
                        // "reconnecting" and keeps its audio supervisor redialing,
                        // rather than tearing everything down. (Treating this as a
                        // PeerLeft is exactly what defeated reconnect in the field —
                        // it aborted the supervisor ~30s in.) We still drop our
                        // cached presence entry; a rejoin re-announces as new.
                        let removed = peers.lock().unwrap().remove(&peer_id).is_some();
                        if removed {
                            disconnected_peers.lock().unwrap().insert(peer_id);
                            crate::log_msg(&format!(
                                "Peer connection lost (NeighborDown): {:?}",
                                peer_id
                            ));
                            let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
                        }
                    }
                    Ok(other) => {
                        crate::log_msg(&format!("Gossip other event: {:?}", other));
                    }
                    Err(e) => {
                        crate::log_msg(&format!("Gossip error event: {:?}", e));
                    }
                }
            }
            crate::log_msg("Gossip topic loop terminated");
        });

        *self.active_topic.lock().unwrap() = Some(handle);
        Ok(())
    }

    async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> {
        crate::log_msg(&format!(
            "RoomState::update_self_state: state: {}",
            peer_state_for_log(&self_state)
        ));
        *self.self_state.lock().unwrap() = Some(self_state.clone());

        let sender_opt = self.active_sender.lock().unwrap().clone();
        let topic_opt = *self.active_topic_bytes.lock().unwrap();
        if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
            let payload = sign_gossip(
                &self.secret_key,
                &topic,
                now_millis(),
                GossipMessage::Announce(self_state),
            );
            if let Ok(bytes) = serde_json::to_vec(&payload) {
                crate::log_msg("Broadcasting updated self state to gossip");
                sender
                    .broadcast(bytes.into())
                    .await
                    .map_err(|e| NetError::Gossip(e.to_string()))?;
            }
        }
        Ok(())
    }

    async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError> {
        let self_id = self._endpoint.id();
        let mut peer_ids = Vec::new();
        for addr in peers {
            if addr.id == self_id || peer_ids.contains(&addr.id) {
                continue;
            }
            self.address_lookup.add_endpoint_info(addr.clone());
            peer_ids.push(addr.id);
        }

        if peer_ids.is_empty() {
            return Ok(());
        }

        // Clone the sender before awaiting: active_sender is a standard mutex and
        // must never be held across an async gossip operation.
        let sender = self
            .active_sender
            .lock()
            .unwrap()
            .clone()
            .ok_or_else(|| NetError::Other("Not in a room".to_string()))?;

        crate::log_msg(&format!("Rebootstrapping gossip peers: {:?}", peer_ids));
        sender
            .join_peers(peer_ids)
            .await
            .map_err(|e| NetError::Gossip(e.to_string()))
    }

    fn mark_peer_disconnected(&self, peer_id: EndpointId) {
        if self.peers.lock().unwrap().remove(&peer_id).is_some() {
            self.disconnected_peers.lock().unwrap().insert(peer_id);
        }
    }

    async fn send_chat(
        &self,
        text: String,
        attachment: Option<crate::files::ChatAttachment>,
    ) -> Result<(), NetError> {
        // Enforce the chat text policy at the SIGN point, not only in the UI, so
        // a future non-UI caller can't sign an out-of-policy body (chat-hardening
        // plan Phase 1). Idempotent over the UI's own sanitize pass.
        let text = crate::sanitize::sanitize_chat(&text);
        if text.is_empty() && attachment.is_none() {
            // Nothing visible to send — not an error, just nothing to do.
            return Ok(());
        }
        let name = {
            let guard = self.self_state.lock().unwrap();
            match guard.as_ref() {
                Some(s) => s.name.clone(),
                None => return Err(NetError::Other("Not in a room".to_string())),
            }
        };
        let ts = now_millis();

        let sender_opt = self.active_sender.lock().unwrap().clone();
        let topic_opt = *self.active_topic_bytes.lock().unwrap();
        // A missing sender/topic or an encode failure is a real send failure the
        // caller must see (chat-hardening Phase 5) — silently returning Ok here
        // would let the UI present an unsent message as broadcast.
        let (Some(sender), Some(topic)) = (sender_opt, topic_opt) else {
            return Err(NetError::Other("Not in a room".to_string()));
        };
        let payload = sign_gossip(
            &self.secret_key,
            &topic,
            ts,
            GossipMessage::Chat {
                name,
                text,
                ts,
                attachment,
            },
        );
        let bytes = serde_json::to_vec(&payload)
            .map_err(|e| NetError::Other(format!("Failed to encode chat: {e}")))?;
        sender
            .broadcast(bytes.into())
            .await
            .map_err(|e| NetError::Gossip(e.to_string()))
    }

    async fn leave(&self) -> Result<(), NetError> {
        crate::log_msg("RoomState::leave called");
        {
            let mut handle_guard = self.active_topic.lock().unwrap();
            if let Some(handle) = handle_guard.take() {
                crate::log_msg("Aborting active gossip topic background task");
                handle.abort();
            }
        }

        *self.active_topic_id.lock().unwrap() = None;
        let topic_opt = self.active_topic_bytes.lock().unwrap().take();

        let sender_opt = self.active_sender.lock().unwrap().take();
        if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
            let payload = sign_gossip(&self.secret_key, &topic, now_millis(), GossipMessage::Leave);
            if let Ok(bytes) = serde_json::to_vec(&payload) {
                crate::log_msg("Broadcasting Leave message to gossip");
                let _ = sender.broadcast(bytes.into()).await;
            }
        }

        self.peers.lock().unwrap().clear();
        self.disconnected_peers.lock().unwrap().clear();
        Ok(())
    }

    fn active_peers(&self) -> Vec<(EndpointId, PeerState)> {
        let guard = self.peers.lock().unwrap();
        guard.iter().map(|(k, v)| (*k, v.clone())).collect()
    }

    async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError> {
        let mut rx_guard = self.event_rx.lock().unwrap();
        if let Some(rx) = rx_guard.take() {
            Ok(rx)
        } else {
            Err(NetError::Other("Events already subscribed".to_string()))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::network::PeerState;
    use iroh::SecretKey;
    use std::collections::HashMap;

    fn sample_peer_state_for(id: EndpointId) -> PeerState {
        let addr = iroh::EndpointAddr::from(id);
        PeerState {
            name: "TestPeerGossip".to_string(),
            is_muted: true,
            addr,
            sharing: None,
            avatar: crate::avatar::Avatar::default(),
            game: None,
            music: None,
        }
    }

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

    fn addr_of(id: EndpointId) -> EndpointAddr {
        EndpointAddr::from(id)
    }

    #[test]
    fn peer_state_log_includes_game() {
        let mut state = sample_peer_state_for(fresh_id());
        state.game = Some("Half-Life 2".to_string());
        assert!(peer_state_for_log(&state).contains("game=Some(\"Half-Life 2\")"));

        state.game = None;
        assert!(peer_state_for_log(&state).contains("game=None"));
    }

    #[test]
    fn bootstrap_client_dials_host() {
        // A non-host (client) with no retained peers dials just the ticket host.
        let me = fresh_id();
        let host = fresh_id();
        assert_eq!(compute_bootstrap(me, host, &[]), vec![host]);
    }

    #[test]
    fn bootstrap_host_rejoin_dials_retained_peers() {
        // The A8 regression: the room creator's ticket names themselves as host,
        // so the ticket-host contributes nothing — the retained peer is the only
        // (and essential) dial target. An empty result here would reproduce A8.
        let me = fresh_id();
        let peer = fresh_id();
        let bootstrap = compute_bootstrap(me, /* ticket_host = */ me, &[addr_of(peer)]);
        assert_eq!(bootstrap, vec![peer]);
    }

    #[test]
    fn bootstrap_host_rejoin_with_no_retained_peers_is_empty() {
        // Host rejoin before anyone was ever seen: nothing to dial (unavoidable),
        // but it must not contain self.
        let me = fresh_id();
        assert!(compute_bootstrap(me, me, &[]).is_empty());
    }

    #[test]
    fn bootstrap_excludes_self_and_dedups_host() {
        // Self is never dialed (even if it shows up among retained peers), and a
        // peer that equals the ticket host isn't listed twice.
        let me = fresh_id();
        let host = fresh_id();
        let other = fresh_id();
        let extra = [addr_of(me), addr_of(host), addr_of(other)];
        let bootstrap = compute_bootstrap(me, host, &extra);
        assert_eq!(bootstrap, vec![host, other]);
        assert!(!bootstrap.contains(&me));
    }

    #[test]
    fn admit_into_roster_caps_new_authors_but_not_updates() {
        // New authors are admitted while there's room...
        assert!(admit_into_roster(0, true, 3));
        assert!(admit_into_roster(2, true, 3));
        // ...rejected once the roster is full...
        assert!(!admit_into_roster(3, true, 3));
        assert!(!admit_into_roster(10, true, 3));
        // ...but an existing peer's update always passes, even at/over the cap.
        assert!(admit_into_roster(3, false, 3));
        assert!(admit_into_roster(99, false, 3));
    }

    #[test]
    fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
        // A brand-new author counts against the cap...
        assert!(announce_subject_to_cap(
            /* is_new */ true, /* is_reconnecting */ false
        ));
        // ...but an ordinary update from an in-roster peer does not...
        assert!(!announce_subject_to_cap(false, false));
        // ...and neither does a re-announce from a peer mid-reconnect, even
        // though it was removed from the live roster (the F-01-audit fix: a full
        // room must not reject a legitimately reconnecting member).
        assert!(!announce_subject_to_cap(true, true));
        // Combined with admit_into_roster: a reconnecting author passes at a full
        // roster, a brand-new one does not.
        assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
        assert!(!admit_into_roster(
            3,
            announce_subject_to_cap(true, false),
            3
        ));
    }

    #[test]
    fn prune_stale_mutations_drops_only_out_of_window_entries() {
        let a = fresh_id();
        let b = fresh_id();
        let mut seen = HashMap::new();
        seen.insert((a, StateMutationKind::Announce), 10_000u64);
        seen.insert((b, StateMutationKind::Leave), 250_000u64);
        // now = 300_000, window = 120_000 → floor 180_000. The 10_000 entry is
        // stale (and could never gate an in-window message), the 250_000 is live.
        prune_stale_mutations(&mut seen, 300_000, GOSSIP_FRESHNESS_MS);
        assert_eq!(seen.len(), 1);
        assert!(seen.contains_key(&(b, StateMutationKind::Leave)));
        assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
    }

    #[test]
    fn clock_skew_monitor_single_drop_does_not_warn() {
        let author = fresh_id();
        let mut monitor = ClockSkewMonitor::default();

        assert_eq!(monitor.observe(author, -121_000, 10_000), None);
    }

    #[test]
    fn clock_skew_monitor_three_drops_in_window_warn_once() {
        let author = fresh_id();
        let mut monitor = ClockSkewMonitor::default();

        assert_eq!(monitor.observe(author, -121_000, 10_000), None);
        assert_eq!(monitor.observe(author, -122_000, 40_000), None);
        assert_eq!(
            monitor.observe(author, -123_000, 69_999),
            Some(ClockSkewWarning {
                author,
                skew_ms: -123_000
            })
        );
        assert_eq!(monitor.observe(author, -124_000, 70_000), None);
    }

    #[test]
    fn clock_skew_monitor_cooldown_suppresses_repeats() {
        let author = fresh_id();
        let mut monitor = ClockSkewMonitor::default();

        assert_eq!(monitor.observe(author, 121_000, 0), None);
        assert_eq!(monitor.observe(author, 122_000, 10_000), None);
        assert!(monitor.observe(author, 123_000, 20_000).is_some());

        assert_eq!(monitor.observe(author, 124_000, 30_000), None);
        assert_eq!(monitor.observe(author, 125_000, 310_000), None);
        assert_eq!(monitor.observe(author, 126_000, 319_000), None);
        assert_eq!(monitor.observe(author, 127_000, 319_999), None);
        assert_eq!(
            monitor.observe(author, 128_000, 320_000),
            Some(ClockSkewWarning {
                author,
                skew_ms: 128_000
            })
        );
    }

    #[test]
    fn clock_skew_monitor_tracks_distinct_authors_independently() {
        let a = fresh_id();
        let b = fresh_id();
        let mut monitor = ClockSkewMonitor::default();

        assert_eq!(monitor.observe(a, -121_000, 0), None);
        assert_eq!(monitor.observe(a, -121_000, 1_000), None);
        assert_eq!(monitor.observe(b, 121_000, 0), None);
        assert_eq!(monitor.observe(b, 121_000, 1_000), None);
        assert_eq!(
            monitor.observe(b, 121_000, 2_000),
            Some(ClockSkewWarning {
                author: b,
                skew_ms: 121_000
            })
        );
        assert_eq!(
            monitor.observe(a, -121_000, 2_000),
            Some(ClockSkewWarning {
                author: a,
                skew_ms: -121_000
            })
        );
    }

    #[test]
    fn clock_skew_monitor_prunes_stale_authors_when_over_cap() {
        let mut monitor = ClockSkewMonitor::default();
        for _ in 0..=CLOCK_SKEW_AUTHORS_SOFT_CAP {
            assert_eq!(monitor.observe(fresh_id(), -121_000, 1), None);
        }
        assert!(monitor.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP);

        let current = fresh_id();
        assert_eq!(
            monitor.observe(current, -121_000, CLOCK_SKEW_AUTHOR_TTL_MS + 2),
            None
        );
        assert_eq!(monitor.authors.len(), 1);
        assert!(monitor.authors.contains_key(&current));
    }

    #[test]
    fn clock_skew_monitor_hard_cap_bounds_fresh_author_growth() {
        let mut monitor = ClockSkewMonitor::default();
        for now_ms in 0..(CLOCK_SKEW_AUTHORS_HARD_CAP as u64 + 10) {
            let _ = monitor.observe(fresh_id(), -121_000, now_ms);
            assert!(monitor.authors.len() <= CLOCK_SKEW_AUTHORS_HARD_CAP);
        }
    }

    #[test]
    fn sanitize_endpoint_addr_caps_address_count() {
        use std::net::SocketAddr;
        let id = fresh_id();
        // An insider stuffs far more addresses than MAX_PEER_ADDRS into one announce.
        let many: Vec<TransportAddr> = (0..(MAX_PEER_ADDRS as u16 + 50))
            .map(|i| TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], 1000 + i))))
            .collect();
        let addr = EndpointAddr::from_parts(id, many);
        let out = sanitize_endpoint_addr(&addr);
        assert_eq!(out.id, id);
        assert_eq!(out.addrs.len(), MAX_PEER_ADDRS);
    }

    #[test]
    fn sanitize_endpoint_addr_drops_overlong_relay_url() {
        use std::str::FromStr;
        let id = fresh_id();
        let short = iroh::RelayUrl::from_str("https://relay.example/").unwrap();
        let long = iroh::RelayUrl::from_str(&format!(
            "https://relay.example/{}",
            "a".repeat(MAX_RELAY_URL_LEN)
        ))
        .unwrap();
        assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
        let addr = EndpointAddr::from_parts(
            id,
            [
                TransportAddr::Relay(short.clone()),
                TransportAddr::Relay(long),
            ],
        );
        let out = sanitize_endpoint_addr(&addr);
        let relays: Vec<_> = out.relay_urls().cloned().collect();
        assert_eq!(relays, vec![short], "over-long relay URL must be dropped");
    }

    #[test]
    fn test_gossip_message_leave_round_trip() {
        let original = GossipMessage::Leave;
        let serialized = serde_json::to_string(&original).unwrap();
        let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
        assert!(matches!(deserialized, GossipMessage::Leave));
    }

    #[test]
    fn test_gossip_payload_announce_round_trip() {
        let secret = SecretKey::generate();
        let topic = [9u8; 32];
        let peer_state = sample_peer_state_for(secret.public());
        let payload = sign_gossip(
            &secret,
            &topic,
            1000,
            GossipMessage::Announce(peer_state.clone()),
        );

        let serialized = serde_json::to_string(&payload).unwrap();
        let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();

        // Author is derived from the signing key, and the signed payload still
        // verifies after a serde round-trip.
        assert_eq!(deserialized.author, secret.public());
        assert!(verify_gossip(&deserialized, &topic, 1000, GOSSIP_FRESHNESS_MS).is_ok());
        match deserialized.msg {
            GossipMessage::Announce(state) => {
                assert_eq!(state, peer_state);
            }
            GossipMessage::Leave | GossipMessage::Chat { .. } => {
                panic!("Expected GossipMessage::Announce");
            }
        }
    }

    #[test]
    fn test_gossip_message_chat_round_trip() {
        // Test normal chat message
        let original = GossipMessage::Chat {
            name: "Alice".to_string(),
            text: "Hello".to_string(),
            ts: 123456789,
            attachment: None,
        };
        let serialized = serde_json::to_string(&original).unwrap();
        let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
        if let GossipMessage::Chat {
            name,
            text,
            ts,
            attachment,
        } = deserialized
        {
            assert_eq!(name, "Alice");
            assert_eq!(text, "Hello");
            assert_eq!(ts, 123456789);
            assert_eq!(attachment, None);
        } else {
            panic!("Expected GossipMessage::Chat");
        }

        // Test empty strings and large timestamp
        let original_empty = GossipMessage::Chat {
            name: "".to_string(),
            text: "".to_string(),
            ts: u64::MAX,
            attachment: None,
        };
        let serialized_empty = serde_json::to_string(&original_empty).unwrap();
        let deserialized_empty: GossipMessage = serde_json::from_str(&serialized_empty).unwrap();
        if let GossipMessage::Chat { name, text, ts, .. } = deserialized_empty {
            assert_eq!(name, "");
            assert_eq!(text, "");
            assert_eq!(ts, u64::MAX);
        } else {
            panic!("Expected GossipMessage::Chat");
        }
    }

    #[test]
    fn test_gossip_chat_attachment_round_trip_and_back_compat() {
        let att = crate::files::ChatAttachment {
            name: "photo.png".to_string(),
            size: 4096,
            kind: crate::files::AttachmentKind::Image,
            id: [42u8; 32],
        };
        let original = GossipMessage::Chat {
            name: "Alice".to_string(),
            text: "look at this".to_string(),
            ts: 1,
            attachment: Some(att.clone()),
        };
        let serialized = serde_json::to_string(&original).unwrap();
        let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
        if let GossipMessage::Chat { attachment, .. } = deserialized {
            assert_eq!(attachment, Some(att));
        } else {
            panic!("Expected GossipMessage::Chat");
        }

        // A pre-v2 chat payload (no `attachment` field) must still deserialize,
        // defaulting the attachment to None (serde(default)).
        let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#;
        let parsed: GossipMessage = serde_json::from_str(legacy).unwrap();
        if let GossipMessage::Chat {
            name, attachment, ..
        } = parsed
        {
            assert_eq!(name, "Old");
            assert_eq!(attachment, None);
        } else {
            panic!("Expected GossipMessage::Chat");
        }
    }

    #[test]
    fn test_gossip_payload_chat_round_trip() {
        let secret = SecretKey::generate();
        let topic = [3u8; 32];
        let payload = sign_gossip(
            &secret,
            &topic,
            987654321,
            GossipMessage::Chat {
                name: "Bob".to_string(),
                text: "Hi there".to_string(),
                ts: 987654321,
                attachment: None,
            },
        );

        let serialized = serde_json::to_string(&payload).unwrap();
        let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();

        assert_eq!(deserialized.author, secret.public());
        if let GossipMessage::Chat { name, text, ts, .. } = deserialized.msg {
            assert_eq!(name, "Bob");
            assert_eq!(text, "Hi there");
            assert_eq!(ts, 987654321);
        } else {
            panic!("Expected GossipMessage::Chat");
        }
    }

    #[test]
    fn test_gossip_chat_unicode_round_trip() {
        let original = GossipMessage::Chat {
            name: "🎙 User".to_string(),
            text: "héllo 🎙 世界".to_string(),
            ts: 1717171717,
            attachment: None,
        };
        let serialized = serde_json::to_string(&original).unwrap();
        let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
        if let GossipMessage::Chat { name, text, ts, .. } = deserialized {
            assert_eq!(name, "🎙 User");
            assert_eq!(text, "héllo 🎙 世界");
            assert_eq!(ts, 1717171717);
        } else {
            panic!("Expected GossipMessage::Chat");
        }
    }

    // --- S2 authentication (verify_gossip) ---

    #[test]
    fn verify_accepts_a_genuine_signed_payload() {
        let secret = SecretKey::generate();
        let topic = [1u8; 32];
        let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
        assert_eq!(
            verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
            Ok(())
        );
    }

    #[test]
    fn verify_rejects_a_forged_author() {
        // Attacker signs with their OWN key but claims to be the victim — the
        // classic S2 spoof. The signature can't validate against the victim key.
        let attacker = SecretKey::generate();
        let victim = SecretKey::generate();
        let topic = [2u8; 32];
        let mut p = sign_gossip(&attacker, &topic, 5_000, GossipMessage::Leave);
        p.author = victim.public(); // forge the claimed author
        assert_eq!(
            verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
            Err(GossipReject::BadSignature)
        );
    }

    #[test]
    fn verify_rejects_a_tampered_message() {
        // Flipping the message after signing must invalidate the signature.
        let secret = SecretKey::generate();
        let topic = [4u8; 32];
        let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
        p.msg = GossipMessage::Chat {
            name: "x".into(),
            text: "y".into(),
            ts: 5_000,
            attachment: None,
        };
        assert_eq!(
            verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
            Err(GossipReject::BadSignature)
        );
    }

    #[test]
    fn verify_rejects_cross_room_replay() {
        // A payload signed for one topic must not validate against another room
        // (the topic is bound into the signed bytes).
        let secret = SecretKey::generate();
        let p = sign_gossip(&secret, &[7u8; 32], 5_000, GossipMessage::Leave);
        assert_eq!(
            verify_gossip(&p, &[8u8; 32], 5_000, GOSSIP_FRESHNESS_MS),
            Err(GossipReject::BadSignature)
        );
    }

    #[test]
    fn verify_rejects_stale_and_future_timestamps() {
        let secret = SecretKey::generate();
        let topic = [5u8; 32];
        let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave);
        // Far in the past relative to "now" → stale (replay).
        assert_eq!(
            verify_gossip(
                &p,
                &topic,
                1_000_000 + GOSSIP_FRESHNESS_MS + 1,
                GOSSIP_FRESHNESS_MS
            ),
            Err(GossipReject::OutOfWindow)
        );
        // Implausibly future.
        assert_eq!(
            verify_gossip(
                &p,
                &topic,
                1_000_000 - GOSSIP_FRESHNESS_MS - 1,
                GOSSIP_FRESHNESS_MS
            ),
            Err(GossipReject::OutOfWindow)
        );
        // Within the window (clock skew tolerance) → accepted.
        assert!(
            verify_gossip(
                &p,
                &topic,
                1_000_000 + GOSSIP_FRESHNESS_MS - 1,
                GOSSIP_FRESHNESS_MS
            )
            .is_ok()
        );
    }

    #[test]
    fn verify_rejects_announce_with_address_for_another_identity() {
        let signer = SecretKey::generate();
        let advertised = SecretKey::generate();
        let topic = [6u8; 32];
        let state = sample_peer_state_for(advertised.public());
        let p = sign_gossip(&signer, &topic, 5_000, GossipMessage::Announce(state));

        assert_eq!(
            verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
            Err(GossipReject::AnnounceAddressMismatch)
        );
    }

    #[test]
    fn state_mutation_replay_gate_drops_replayed_leave_and_announce() {
        let author = fresh_id();
        let mut seen = HashMap::new();

        assert!(admit_state_mutation(
            &mut seen,
            author,
            &GossipMessage::Leave,
            10
        ));
        assert!(!admit_state_mutation(
            &mut seen,
            author,
            &GossipMessage::Leave,
            10
        ));
        assert!(!admit_state_mutation(
            &mut seen,
            author,
            &GossipMessage::Leave,
            9
        ));
        assert!(admit_state_mutation(
            &mut seen,
            author,
            &GossipMessage::Leave,
            11
        ));

        let announce = GossipMessage::Announce(sample_peer_state_for(author));
        assert!(admit_state_mutation(&mut seen, author, &announce, 10));
        assert!(!admit_state_mutation(&mut seen, author, &announce, 10));
        assert!(!admit_state_mutation(&mut seen, author, &announce, 9));
        assert!(admit_state_mutation(&mut seen, author, &announce, 12));
    }

    #[test]
    fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
        let author = fresh_id();
        let mut seen = HashMap::new();
        let later_chat = GossipMessage::Chat {
            name: "A".into(),
            text: "later".into(),
            ts: 200,
            attachment: None,
        };
        let earlier_chat = GossipMessage::Chat {
            name: "A".into(),
            text: "earlier".into(),
            ts: 100,
            attachment: None,
        };

        assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
        assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
        assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
        assert!(
            seen.is_empty(),
            "chat must not populate the state-mutation replay map"
        );
    }

    #[test]
    fn state_mutation_replay_gate_is_per_author_and_kind() {
        let author = fresh_id();
        let other = fresh_id();
        let mut seen = HashMap::new();
        let announce = GossipMessage::Announce(sample_peer_state_for(author));

        assert!(admit_state_mutation(
            &mut seen,
            author,
            &GossipMessage::Leave,
            5
        ));
        assert!(admit_state_mutation(&mut seen, author, &announce, 5));
        assert!(admit_state_mutation(
            &mut seen,
            other,
            &GossipMessage::Leave,
            5
        ));
    }

    // ---- Chat-hardening Phase 2: ingress gate (replay dedup + token buckets) ----

    /// Distinct opaque "signature" bytes; the gate never inspects them beyond
    /// equality, so a counter-stamped array stands in for a real signature.
    fn sig(n: u64) -> [u8; 64] {
        let mut bytes = [0u8; 64];
        bytes[..8].copy_from_slice(&n.to_le_bytes());
        bytes
    }

    const T0: u64 = 1_000_000_000_000;

    #[test]
    fn token_bucket_burst_and_refill_boundaries() {
        let mut bucket = TokenBucket::full(CHAT_AUTHOR_BURST, T0);
        for _ in 0..CHAT_AUTHOR_BURST as usize {
            assert!(bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0));
        }
        // Burst exhausted at the same instant.
        assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0));
        // 999 ms refills just under one token at 1/s…
        assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 999));
        // …a full second refills exactly one (999 ms already banked 0.999 of it,
        // so take at the accumulated boundary).
        assert!(bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 1_001));
        assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 1_001));
    }

    #[test]
    fn chat_gate_rejects_unknown_author() {
        let mut gate = ChatIngressGate::new(T0);
        let author = fresh_id();
        assert_eq!(
            gate.admit(false, author, &sig(1), T0, T0),
            Err(ChatReject::UnknownAuthor)
        );
        // Same frame from a known author is fine — nothing was consumed above.
        assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
    }

    #[test]
    fn chat_gate_suppresses_exact_replay_but_admits_distinct_same_ms() {
        let mut gate = ChatIngressGate::new(T0);
        let author = fresh_id();
        assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
        // Two DISTINCT chats signed in the same millisecond both land…
        assert_eq!(gate.admit(true, author, &sig(2), T0, T0), Ok(()));
        // …but the byte-identical frame is a replay, from any deliverer.
        assert_eq!(
            gate.admit(true, author, &sig(1), T0, T0),
            Err(ChatReject::Replay)
        );
    }

    #[test]
    fn chat_gate_author_burst_then_refill() {
        let mut gate = ChatIngressGate::new(T0);
        let author = fresh_id();
        for n in 0..CHAT_AUTHOR_BURST as u64 {
            assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
        }
        assert_eq!(
            gate.admit(true, author, &sig(99), T0, T0),
            Err(ChatReject::RateLimited)
        );
        // One second later the author has exactly one more message.
        assert_eq!(gate.admit(true, author, &sig(100), T0, T0 + 1_000), Ok(()));
        assert_eq!(
            gate.admit(true, author, &sig(101), T0, T0 + 1_000),
            Err(ChatReject::RateLimited)
        );
    }

    #[test]
    fn chat_gate_replay_never_consumes_tokens() {
        let mut gate = ChatIngressGate::new(T0);
        let author = fresh_id();
        for n in 0..CHAT_AUTHOR_BURST as u64 {
            assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
        }
        // Replays of an admitted frame while exhausted report Replay (dedup runs
        // BEFORE the buckets) and burn no tokens…
        for _ in 0..50 {
            assert_eq!(
                gate.admit(true, author, &sig(0), T0, T0 + 1_000),
                Err(ChatReject::Replay)
            );
        }
        // …so the token refilled at +1s is still there for a NEW message.
        assert_eq!(gate.admit(true, author, &sig(200), T0, T0 + 1_000), Ok(()));
    }

    #[test]
    fn chat_gate_rate_limited_frame_is_not_marked_replayed() {
        let mut gate = ChatIngressGate::new(T0);
        let author = fresh_id();
        for n in 0..CHAT_AUTHOR_BURST as u64 {
            assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
        }
        // Rejected for rate only — NOT entered into the replay cache…
        assert_eq!(
            gate.admit(true, author, &sig(300), T0, T0),
            Err(ChatReject::RateLimited)
        );
        // …so the same signed frame redelivered after refill is admitted once.
        assert_eq!(gate.admit(true, author, &sig(300), T0, T0 + 1_000), Ok(()));
    }

    #[test]
    fn chat_gate_room_bucket_bounds_sock_puppet_authors() {
        let mut gate = ChatIngressGate::new(T0);
        // 40 distinct authors, one message each, same instant: per-author buckets
        // are all full, so only the room-wide burst bounds admission.
        let mut admitted = 0;
        for n in 0..40u64 {
            if gate.admit(true, fresh_id(), &sig(n), T0, T0).is_ok() {
                admitted += 1;
            }
        }
        assert_eq!(admitted, CHAT_ROOM_BURST as usize);
        // The room refills at 8/s: exactly 8 more land a second later, even from
        // fresh authors whose own buckets are full — the room bound decides.
        let mut late_admitted = 0;
        for n in 100..120u64 {
            if gate
                .admit(true, fresh_id(), &sig(n), T0, T0 + 1_000)
                .is_ok()
            {
                late_admitted += 1;
            }
        }
        assert_eq!(late_admitted, 8);
        // Chat admission being room-bounded is what keeps the event channel
        // available for control messages: an Announce is gated independently.
        let mut seen = HashMap::new();
        assert!(admit_state_mutation(
            &mut seen,
            fresh_id(),
            &GossipMessage::Leave,
            T0 + 1_000
        ));
    }

    #[test]
    fn chat_gate_room_reject_refunds_the_author_token() {
        let mut gate = ChatIngressGate::new(T0);
        // Author A drains the whole room burst alone? No — its own burst is 8.
        // Use 4 authors × 8 to empty the room exactly.
        let mut n = 0u64;
        for _ in 0..4 {
            let author = fresh_id();
            for _ in 0..CHAT_AUTHOR_BURST as usize {
                assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
                n += 1;
            }
        }
        // A 5th author is room-rejected 8 times, but its own bucket is refunded
        // each time…
        let victim = fresh_id();
        for _ in 0..CHAT_AUTHOR_BURST as usize {
            assert_eq!(
                gate.admit(true, victim, &sig(n), T0, T0),
                Err(ChatReject::RateLimited)
            );
            n += 1;
        }
        // …so when the room refills, the victim still has its FULL burst.
        let mut admitted = 0;
        for _ in 0..CHAT_AUTHOR_BURST as usize {
            if gate.admit(true, victim, &sig(n), T0, T0 + 1_000).is_ok() {
                admitted += 1;
            }
            n += 1;
        }
        assert_eq!(admitted, CHAT_AUTHOR_BURST as usize);
    }

    #[test]
    fn chat_replay_cache_prunes_by_ttl_and_cap() {
        let mut gate = ChatIngressGate::new(T0);
        let author = fresh_id();
        // TTL: an admitted frame's entry is dropped once its SIGNED ts falls out
        // of the freshness window (it could no longer pass verify_gossip anyway).
        assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
        assert_eq!(gate.seen.len(), 1);
        gate.prune_replay_cache(T0 + GOSSIP_FRESHNESS_MS + 1);
        assert!(gate.seen.is_empty() && gate.seen_order.is_empty());

        // Hard cap: stuff the cache directly (admission itself is rate-limited
        // far below the cap) and verify FIFO-oldest eviction bounds it.
        for n in 0..(CHAT_REPLAY_CACHE_CAP as u64 + 100) {
            gate.seen.insert(sig(n));
            gate.seen_order.push_back((sig(n), T0));
        }
        gate.prune_replay_cache(T0);
        assert!(gate.seen_order.len() < CHAT_REPLAY_CACHE_CAP);
        assert_eq!(gate.seen.len(), gate.seen_order.len());
        // The oldest entries went first.
        assert!(!gate.seen.contains(&sig(0)));
        assert!(gate.seen.contains(&sig(CHAT_REPLAY_CACHE_CAP as u64 + 99)));
    }

    #[test]
    fn chat_gate_author_bucket_map_stays_bounded() {
        let mut gate = ChatIngressGate::new(T0);
        for n in 0..(CHAT_AUTHOR_BUCKETS_CAP as u64 * 2) {
            let _ = gate.admit(true, fresh_id(), &sig(n), T0, T0 + n);
        }
        assert!(gate.authors.len() <= CHAT_AUTHOR_BUCKETS_CAP);
    }

    #[test]
    fn chat_gate_evict_author_drops_limiter_state() {
        let mut gate = ChatIngressGate::new(T0);
        let author = fresh_id();
        assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
        assert!(gate.authors.contains_key(&author));
        gate.evict_author(&author);
        assert!(!gate.authors.contains_key(&author));
    }

    #[test]
    fn chat_gate_reject_logging_is_squelched_per_author() {
        let mut gate = ChatIngressGate::new(T0);
        let author = fresh_id();
        // Establish limiter state, then exhaust it.
        for n in 0..=CHAT_AUTHOR_BURST as u64 {
            let _ = gate.admit(true, author, &sig(n), T0, T0);
        }
        assert!(gate.should_log_reject(true, author, T0));
        assert!(!gate.should_log_reject(true, author, T0 + 1));
        assert!(gate.should_log_reject(true, author, T0 + CHAT_REJECT_LOG_COOLDOWN_MS));

        // Unknown authors share one squelch slot (they have no map entry).
        let stranger = fresh_id();
        assert!(gate.should_log_reject(false, stranger, T0));
        assert!(!gate.should_log_reject(false, fresh_id(), T0 + 1));
    }
}
