//! Sanitizers for untrusted peer-supplied strings shared across layers.
//!
//! Peer display names ride the gossip presence plane (`PeerState.name`), which is
//! untrusted and spoofable, yet they're rendered directly in the roster. This
//! module cleans a name at the gossip ingest point so every downstream consumer
//! gets a safe value (security finding S4). Chat text policy ([`sanitize_chat`],
//! [`cap_chat_input`], [`admit_chat_text`]) also lives here so the UI, the gossip
//! sign point, and the gossip ingress all enforce the same ceilings.

/// Max characters kept for a peer's display name after sanitizing. Names are
/// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps
/// the roster readable.
pub const NAME_MAX_CHARS: usize = 48;

/// Bidirectional override/isolate format characters (`General_Category=Cf`, NOT
/// caught by [`char::is_control`]) that can visually reorder surrounding text.
/// Stripped even from expressive chat bodies (security finding S14): unlike the
/// benign zero-width joiners/marks, these let a sender make rendered text read
/// differently from what was actually sent.
pub(crate) fn is_bidi_override_char(c: char) -> bool {
    matches!(c,
        '\u{202A}'..='\u{202E}'   // LRE, RLE, PDF, LRO, RLO (bidi overrides)
        | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
    )
}

/// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a
/// rendered name even though they are NOT caught by [`char::is_control`]:
/// bidirectional overrides/isolates (text-direction spoofing) and
/// zero-width / BOM characters (invisible, can hide or fake content). Listed
/// explicitly so the sanitizer stays dependency-free (std exposes no category
/// query). Stripped outright rather than replaced.
pub(crate) fn is_spoofing_format_char(c: char) -> bool {
    is_bidi_override_char(c)
        || matches!(c,
            '\u{200B}'..='\u{200F}'   // zero-width space, ZWNJ, ZWJ, LRM, RLM
            | '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
            | '\u{FEFF}'              // BOM / zero-width no-break space
        )
}

/// Max characters kept for a broadcast game-presence label after sanitizing
/// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]),
/// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string
/// can't blow the presence frame.
pub const GAME_LABEL_MAX_CHARS: usize = 64;
/// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of
/// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width.
pub const GAME_LABEL_MAX_BYTES: usize = 256;

/// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing
/// format characters, turn control characters into spaces, collapse any whitespace
/// run to a single space, and trim the ends. Length capping is the caller's job.
fn clean_label(input: &str) -> String {
    let cleaned: String = input
        .chars()
        .filter(|c| !is_spoofing_format_char(*c))
        .map(|c| if c.is_control() { ' ' } else { c })
        .collect();
    cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
/// zero-width format characters, turns control characters into spaces, collapses
/// any whitespace run to a single space, trims the ends, and caps the length at
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
/// substitute a placeholder such as a short id).
pub fn sanitize_name(input: &str) -> String {
    clean_label(input).chars().take(NAME_MAX_CHARS).collect()
}

/// Sanitize an untrusted game-presence label (the `Playing <name>` status that
/// rides the gossip presence plane). Same spoof/control cleaning as
/// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND
/// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and
/// any incoming peer label. Returns `""` if nothing usable remains (no broadcast).
pub fn sanitize_game_label(input: &str) -> String {
    let mut out = String::new();
    for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) {
        if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES {
            break;
        }
        out.push(c);
    }
    out
}

/// Max characters kept for a single chat message after sanitizing.
pub const CHAT_MSG_MAX_CHARS: usize = 2000;

/// Max UTF-8 bytes kept for a single chat message, enforced alongside
/// [`CHAT_MSG_MAX_CHARS`] (2,000 four-byte scalars would otherwise reach 8,000
/// bytes). This is also the ingress bound: signed peers never produce more, so
/// raw incoming text above it is rejected outright (see [`admit_chat_text`]).
pub const CHAT_MSG_MAX_BYTES: usize = 8 * 1024;

/// Sanitize a chat message body, applied to BOTH our outgoing text (before local
/// echo, and again at the gossip sign point) and incoming peer text (untrusted —
/// a buggy/malicious sender could include control characters or an enormous
/// payload). Single pass: bidi overrides/isolates are stripped outright (S14 —
/// they can visually reorder the rendered line), control characters become
/// spaces, any whitespace run collapses to a single space, the ends are trimmed,
/// and both the character and UTF-8 byte ceilings are enforced without ever
/// splitting a scalar. Message bodies deliberately keep the OTHER format
/// characters (ZWJ/ZWNJ/LRM/RLM etc.) that the short-label sanitizers strip —
/// chat is expressive text, not a label, and those are needed for emoji
/// sequences and joining scripts. Returns `""` for input with no visible text
/// (callers drop empty messages). Idempotent, so layered application converges
/// on the same result.
pub fn sanitize_chat(input: &str) -> String {
    let mut out = String::new();
    let mut chars = 0usize;
    let mut pending_space = false;
    for c in input.chars() {
        if is_bidi_override_char(c) {
            continue;
        }
        let c = if c.is_control() { ' ' } else { c };
        if c.is_whitespace() {
            // Trim: only mark a separator once visible text exists; a trailing
            // run is never emitted because the space lands with the NEXT char.
            pending_space = !out.is_empty();
            continue;
        }
        let sep = usize::from(pending_space);
        if chars + sep + 1 > CHAT_MSG_MAX_CHARS
            || out.len() + sep + c.len_utf8() > CHAT_MSG_MAX_BYTES
        {
            break;
        }
        if pending_space {
            out.push(' ');
            chars += 1;
            pending_space = false;
        }
        out.push(c);
        chars += 1;
    }
    out
}

/// Cap the LIVE chat-input text (typing, clipboard/primary-selection paste,
/// context-menu paste) at the chat ceilings. Unlike [`sanitize_chat`] this
/// preserves the user's whitespace exactly — normalization stays a submit-time
/// operation so the visible text never jumps while editing — and only truncates,
/// always on a scalar boundary. Returns the input unchanged when within bounds.
pub fn cap_chat_input(input: String) -> String {
    if input.len() <= CHAT_MSG_MAX_BYTES && input.chars().count() <= CHAT_MSG_MAX_CHARS {
        return input;
    }
    let mut out = String::new();
    for (chars, c) in input.chars().enumerate() {
        if chars >= CHAT_MSG_MAX_CHARS || out.len() + c.len_utf8() > CHAT_MSG_MAX_BYTES {
            break;
        }
        out.push(c);
    }
    out
}

/// Gossip-ingress admission for an untrusted incoming chat body. `None` drops
/// the message: raw text over the byte ceiling is rejected BEFORE any
/// sanitization work (a compliant sender sanitizes before signing, so oversized
/// text is a protocol violation, not something to repair), and a message with
/// neither visible text nor an attachment carries nothing to show. Otherwise
/// yields the sanitized (possibly empty, attachment-only) body to forward.
pub fn admit_chat_text(raw: &str, has_attachment: bool) -> Option<String> {
    if raw.len() > CHAT_MSG_MAX_BYTES {
        return None;
    }
    let text = sanitize_chat(raw);
    (!text.is_empty() || has_attachment).then_some(text)
}

/// Max clickable links rendered per chat message. Later URL candidates stay
/// selectable plain text — bounds both the span count a message can force the
/// renderer to build and the opener targets one line can carry.
pub const CHAT_MSG_MAX_LINKS: usize = 8;

/// Trailing characters commonly adjacent to a URL in prose that should NOT be
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
fn is_url_trailing_punct(c: char) -> bool {
    matches!(
        c,
        '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\''
    )
}

/// Find the byte index of the earliest `http://` or `https://` scheme in `s`
/// (ASCII-case-insensitive, so a sentence-capitalized "Http://…" still counts),
/// scanning only on char boundaries so slicing is always safe.
fn find_scheme(s: &str) -> Option<usize> {
    s.char_indices().find_map(|(i, _)| {
        let tail = &s[i..];
        let matches_prefix = |p: &str| {
            tail.get(..p.len())
                .is_some_and(|t| t.eq_ignore_ascii_case(p))
        };
        (matches_prefix("http://") || matches_prefix("https://")).then_some(i)
    })
}

/// The clickable-link policy, shared by link detection ([`link_ranges`]) and the
/// opener's defence-in-depth re-check (`AppMessage::OpenUrl`): the candidate must
/// parse as a URL with an `http`/`https` scheme, a non-empty host, and NO
/// username/password syntax (`http://user@host` reads as a credential but is a
/// classic destination-spoof — such text stays plain, never clickable).
pub fn is_safe_web_url(s: &str) -> bool {
    let Ok(u) = url::Url::parse(s) else {
        return false;
    };
    matches!(u.scheme(), "http" | "https")
        && u.host_str().is_some_and(|h| !h.is_empty())
        && u.username().is_empty()
        && u.password().is_none()
}

/// Detect clickable links in an (already chat-sanitized) message, returning the
/// byte range of each — computed ONCE when a message enters history and cached
/// on its entry, so redraws slice instead of rescanning. **Conservative on
/// purpose:** only `http://` / `https://` runs count, each ending at the first
/// whitespace with trailing prose punctuation peeled off, and only candidates
/// passing [`is_safe_web_url`] become links — a failing candidate's whole
/// whitespace-delimited run stays plain text (its interior is not re-scanned).
/// At most [`CHAT_MSG_MAX_LINKS`] ranges; ranges are ascending, non-overlapping,
/// and always on char boundaries. The href is exactly the displayed slice, so
/// what the user sees IS what the opener receives.
pub fn link_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
    let mut out = Vec::new();
    let mut base = 0usize;
    while out.len() < CHAT_MSG_MAX_LINKS {
        let Some(start) = find_scheme(&text[base..]) else {
            break;
        };
        let run_start = base + start;
        let run = &text[run_start..];
        let run_end = run.find(char::is_whitespace).unwrap_or(run.len());
        // Peel trailing punctuation back out of the candidate; a run is at least
        // the 7-byte scheme long, so `base` always advances.
        let candidate = run[..run_end].trim_end_matches(is_url_trailing_punct);
        if is_safe_web_url(candidate) {
            out.push(run_start..run_start + candidate.len());
            base = run_start + candidate.len();
        } else {
            base = run_start + run_end;
        }
    }
    out
}

/// Split `text` into `(slice, is_link)` pieces from cached [`link_ranges`]
/// output. Concatenating the slices reproduces `text` exactly (purely a
/// presentational split — no characters added or dropped). Borrows, so a redraw
/// allocates nothing for plain text. `ranges` must come from [`link_ranges`] on
/// this same `text` (ascending, non-overlapping, char-boundary ranges).
pub fn segments<'a>(text: &'a str, ranges: &[std::ops::Range<usize>]) -> Vec<(&'a str, bool)> {
    let mut out = Vec::new();
    let mut pos = 0usize;
    for r in ranges {
        if r.start > pos {
            out.push((&text[pos..r.start], false));
        }
        out.push((&text[r.clone()], true));
        pos = r.end;
    }
    if pos < text.len() {
        out.push((&text[pos..], false));
    }
    out
}

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

    #[test]
    fn keeps_ordinary_names_including_unicode() {
        assert_eq!(sanitize_name("Alice"), "Alice");
        assert_eq!(sanitize_name("héllo 世界"), "héllo 世界");
        // Emoji are not format/control chars — kept.
        assert_eq!(sanitize_name("🎙 Mike"), "🎙 Mike");
    }

    #[test]
    fn strips_control_chars_and_collapses_whitespace() {
        // NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
        // collapse; ends trim.
        assert_eq!(
            sanitize_name("  a\u{0}b\r\nc\td\u{1b}[31m  "),
            "a b c d [31m"
        );
        // A name that is only control/whitespace cleans to empty.
        assert_eq!(sanitize_name("\u{0}\r\n\t   "), "");
    }

    #[test]
    fn strips_bidi_and_zero_width_format_chars() {
        // Right-to-left override (classic name-spoof char) is removed entirely.
        assert_eq!(sanitize_name("admin\u{202E}txt"), "admintxt");
        // Zero-width space / joiner / BOM removed.
        assert_eq!(sanitize_name("ab\u{200B}cd"), "abcd");
        assert_eq!(sanitize_name("\u{FEFF}name"), "name");
        // Bidi isolates removed.
        assert_eq!(sanitize_name("x\u{2066}y\u{2069}z"), "xyz");
    }

    #[test]
    fn caps_length() {
        let long = "n".repeat(NAME_MAX_CHARS + 500);
        assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
    }

    // --- sanitize_game_label ----------------------------------------------

    #[test]
    fn game_label_keeps_ordinary_titles_and_strips_spoofing() {
        assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2");
        // Same spoof/control cleaning as names.
        assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt");
        assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c");
    }

    #[test]
    fn game_label_caps_chars_wider_than_names() {
        // A game label keeps more than a name's 48 (up to 64), so a title between
        // the two caps survives in full.
        let mid = "g".repeat(56);
        assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
        let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
        assert_eq!(
            sanitize_game_label(&long).chars().count(),
            GAME_LABEL_MAX_CHARS
        );
    }

    #[test]
    fn game_label_caps_bytes_for_multibyte_titles() {
        // Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept.
        let cjk = "世".repeat(GAME_LABEL_MAX_CHARS);
        let out = sanitize_game_label(&cjk);
        assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS);
        assert!(out.len() <= GAME_LABEL_MAX_BYTES);
        // Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64,
        // but the leading clean keeps them as a run) — never exceeds the byte cap.
        let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS);
        let out = sanitize_game_label(&emoji);
        assert!(out.len() <= GAME_LABEL_MAX_BYTES);
        assert!(out.chars().all(|c| c == '🎮'));
    }

    #[test]
    fn game_label_empty_when_nothing_usable() {
        assert_eq!(sanitize_game_label("\u{0}\r\n\t   "), "");
    }

    // --- chat body policy ---------------------------------------------------

    #[test]
    fn chat_keeps_ordinary_text_and_unicode() {
        assert_eq!(sanitize_chat("hello world"), "hello world");
        assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
        // Bodies keep format characters that label sanitizers strip: a ZWJ emoji
        // family sequence survives intact.
        let family = "👨\u{200D}👩\u{200D}👧";
        assert_eq!(sanitize_chat(family), family);
    }

    #[test]
    fn chat_strips_control_chars_and_collapses_whitespace() {
        assert_eq!(sanitize_chat("  hi   there  "), "hi there");
        assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
        assert_eq!(sanitize_chat("\u{0}\r\n\t  "), "");
        assert_eq!(sanitize_chat(""), "");
    }

    #[test]
    fn chat_caps_chars_at_exact_boundary_without_trailing_space() {
        let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
        assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
        assert_eq!(
            sanitize_chat(&"x".repeat(CHAT_MSG_MAX_CHARS))
                .chars()
                .count(),
            CHAT_MSG_MAX_CHARS
        );
        // Truncation never leaves a dangling separator: with "word " units the
        // cut lands mid-run, and the output still ends on visible text.
        let words = "word ".repeat(1000);
        let out = sanitize_chat(&words);
        assert!(out.chars().count() <= CHAT_MSG_MAX_CHARS);
        assert!(!out.ends_with(' '));
    }

    #[test]
    fn chat_ceilings_never_split_a_scalar() {
        // Four-byte scalars: the char cap bites first (2,000 × 4 = 8,000 bytes,
        // inside the byte ceiling by design) and the last emoji is kept whole.
        let emoji = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
        let out = sanitize_chat(&emoji);
        assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
        assert!(out.len() <= CHAT_MSG_MAX_BYTES);
        assert!(out.chars().all(|c| c == '🎮'));
        // Three-byte scalars at the char boundary.
        let cjk = "世".repeat(CHAT_MSG_MAX_CHARS + 1);
        let out = sanitize_chat(&cjk);
        assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
        assert!(out.is_char_boundary(out.len()));
    }

    #[test]
    fn chat_sanitize_is_idempotent() {
        for input in [
            "plain text",
            "  spaced \t out\r\n text  ",
            "unicode 🎙 世界 👨\u{200D}👩\u{200D}👧",
            &"word ".repeat(1000),
            &"🎮".repeat(CHAT_MSG_MAX_CHARS + 100),
        ] {
            let once = sanitize_chat(input);
            assert_eq!(sanitize_chat(&once), once, "not idempotent for {input:?}");
        }
    }

    #[test]
    fn cap_chat_input_preserves_whitespace_within_bounds() {
        // In-bounds input comes back byte-identical — no normalization while
        // the user is still editing.
        let draft = "  hello   world \t ".to_string();
        assert_eq!(cap_chat_input(draft.clone()), draft);
    }

    #[test]
    fn cap_chat_input_truncates_oversized_paste_on_scalar_boundary() {
        let paste = "x".repeat(CHAT_MSG_MAX_CHARS + 5000);
        let out = cap_chat_input(paste);
        assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
        let emoji_paste = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
        let out = cap_chat_input(emoji_paste);
        assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
        assert!(out.len() <= CHAT_MSG_MAX_BYTES);
        assert!(out.chars().all(|c| c == '🎮'));
    }

    #[test]
    fn admit_rejects_oversized_raw_bytes_before_sanitizing() {
        // One byte over the ceiling → rejected outright, attachment or not.
        let over = "x".repeat(CHAT_MSG_MAX_BYTES + 1);
        assert_eq!(admit_chat_text(&over, false), None);
        assert_eq!(admit_chat_text(&over, true), None);
        // Exactly at the ceiling → admitted (then sanitized/capped).
        let at = "x".repeat(CHAT_MSG_MAX_BYTES);
        let admitted = admit_chat_text(&at, false).expect("at-ceiling text admitted");
        assert_eq!(admitted.chars().count(), CHAT_MSG_MAX_CHARS);
        // Multibyte raw over the ceiling → rejected.
        let cjk_over = "世".repeat(CHAT_MSG_MAX_BYTES / 3 + 1);
        assert!(cjk_over.len() > CHAT_MSG_MAX_BYTES);
        assert_eq!(admit_chat_text(&cjk_over, false), None);
    }

    #[test]
    fn admit_keeps_attachment_only_messages_and_drops_truly_empty_ones() {
        // No visible text + no attachment → nothing to show, dropped.
        assert_eq!(admit_chat_text("", false), None);
        assert_eq!(admit_chat_text("\u{0}\r\n\t  ", false), None);
        // Same bodies WITH an attachment → kept as an empty caption.
        assert_eq!(admit_chat_text("", true), Some(String::new()));
        assert_eq!(admit_chat_text("\u{0}\r\n\t  ", true), Some(String::new()));
        // Normal text converges on the same result as direct sanitization.
        assert_eq!(
            admit_chat_text("  hi   there  ", false),
            Some(sanitize_chat("  hi   there  "))
        );
    }

    #[test]
    fn chat_strips_bidi_overrides_but_keeps_benign_format_chars() {
        // Overrides and isolates are removed outright (S14) …
        assert_eq!(sanitize_chat("pay \u{202E}gpj.exe now"), "pay gpj.exe now");
        assert_eq!(sanitize_chat("a\u{2066}b\u{2069}c"), "abc");
        assert_eq!(
            sanitize_chat("\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}"),
            ""
        );
        // … while the expressive format characters chat promises to keep — ZWJ
        // (emoji sequences), ZWNJ (joining scripts), LRM/RLM (bidi *marks*, which
        // cannot reorder text) — survive.
        for kept in ['\u{200D}', '\u{200C}', '\u{200E}', '\u{200F}'] {
            let msg = format!("a{kept}b");
            assert_eq!(sanitize_chat(&msg), msg, "stripped benign {kept:?}");
        }
    }

    // --- link policy ---------------------------------------------------------

    /// Concatenating every segment's slice must reproduce the input exactly.
    fn reassemble(text: &str) -> String {
        segments(text, &link_ranges(text))
            .iter()
            .map(|(s, _)| *s)
            .collect()
    }

    /// The link slices of a message, in order.
    fn links(text: &str) -> Vec<&str> {
        segments(text, &link_ranges(text))
            .into_iter()
            .filter_map(|(s, is_link)| is_link.then_some(s))
            .collect()
    }

    #[test]
    fn url_policy_accepts_only_wellformed_web_urls() {
        for ok in [
            "http://example.com",
            "https://a.test/path?q=1&w=2",
            "HTTP://EXAMPLE.COM",             // mixed case scheme+host
            "https://x.com:8443/p",           // explicit port
            "https://d.com/路径?q=世界#frag", // unicode path/query/fragment
            // WHATWG parsing (what browsers do) collapses the extra slash into
            // host "path" — a valid, if odd, destination; not an empty host.
            "http:///path",
        ] {
            assert!(is_safe_web_url(ok), "rejected {ok:?}");
        }
        for bad in [
            "",
            "example.com",             // no scheme
            "http://",                 // empty host
            "ftp://x.com",             // non-web scheme
            "file:///etc/passwd",      // no host, wrong scheme
            "javascript:alert(1)",     // opener must never see this
            "http://user@good.com",    // userinfo → destination spoof risk
            "http://user:pw@good.com", // credentials
            "http://exa mple.com",     // malformed host
        ] {
            assert!(!is_safe_web_url(bad), "accepted {bad:?}");
        }
    }

    #[test]
    fn link_ranges_detects_http_and_https_with_exact_roundtrip() {
        assert_eq!(links("see http://example.com now"), ["http://example.com"]);
        assert_eq!(
            links("a http://one.com b https://two.com c"),
            ["http://one.com", "https://two.com"]
        );
        // Sentence-capitalized scheme still detected; href = the displayed slice.
        assert_eq!(links("go to Http://example.com"), ["Http://example.com"]);
        for msg in [
            "",
            "no urls at all",
            "http://a.com",
            "pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!",
            "weird))) http://c.com]]] tail",
            "unicode 世界 http://d.com/路径 more 世界",
            "bad http:// and http://user@x.com around https://ok.org here",
        ] {
            assert_eq!(reassemble(msg), msg, "roundtrip failed for {msg:?}");
        }
    }

    #[test]
    fn link_ranges_peels_trailing_punctuation() {
        assert_eq!(links("go to https://x.com."), ["https://x.com"]);
        assert_eq!(links("(https://x.com)"), ["https://x.com"]);
    }

    #[test]
    fn link_ranges_leaves_invalid_candidates_as_plain_text() {
        // Non-web schemes and bare domains never linkify (conservative).
        assert_eq!(
            links("email me@x.com or ftp://x.com or visit x.com"),
            [] as [&str; 0]
        );
        // A malformed/deceptive candidate stays text WITHOUT eating a later
        // valid link.
        assert_eq!(links("http:// then https://ok.org"), ["https://ok.org"]);
        assert_eq!(
            links("http://user:pw@evil.com vs https://good.com"),
            ["https://good.com"]
        );
        // An invalid run's interior is not re-scanned for nested schemes.
        assert_eq!(links("http://a@http://b.com"), [] as [&str; 0]);
    }

    #[test]
    fn link_ranges_caps_clickable_links_per_message() {
        let many = (0..CHAT_MSG_MAX_LINKS + 4)
            .map(|i| format!("https://site{i}.test"))
            .collect::<Vec<_>>()
            .join(" ");
        let ranges = link_ranges(&many);
        assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
        // The 9th+ URLs remain, but as plain selectable text.
        assert_eq!(reassemble(&many), many);
        let l = links(&many);
        assert_eq!(l.last(), Some(&"https://site7.test"));
        // Exactly at the cap: all clickable.
        let at_cap = (0..CHAT_MSG_MAX_LINKS)
            .map(|i| format!("https://site{i}.test"))
            .collect::<Vec<_>>()
            .join(" ");
        assert_eq!(link_ranges(&at_cap).len(), CHAT_MSG_MAX_LINKS);
    }

    #[test]
    fn link_ranges_survives_adversarial_many_link_input() {
        // A ceiling-length message packed with minimal URLs: bounded output,
        // exact reconstruction, and every range on char boundaries.
        let flood = "http://a.io ".repeat(CHAT_MSG_MAX_BYTES / 12 + 1);
        let msg = sanitize_chat(&flood);
        let ranges = link_ranges(&msg);
        assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
        for r in &ranges {
            assert!(msg.is_char_boundary(r.start) && msg.is_char_boundary(r.end));
        }
        assert_eq!(reassemble(&msg), msg);
    }
}
