//! 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 has its own sanitizer in
//! the UI layer (`app::sanitize_chat`).

/// 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;

/// 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.
fn is_spoofing_format_char(c: char) -> bool {
    matches!(c,
        '\u{200B}'..='\u{200F}'   // zero-width space, ZWNJ, ZWJ, LRM, RLM
        | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
        | '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
        | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
        | '\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
}

/// A piece of a chat message after URL detection: literal text or a link.
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Segment {
    /// Plain text to render as-is.
    Text(String),
    /// A detected URL to render as a clickable link (also its href).
    Link(String),
}

/// 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`,
/// 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..];
        (tail.starts_with("http://") || tail.starts_with("https://")).then_some(i)
    })
}

/// Split an (already chat-sanitized) message into plain-text and URL [`Segment`]s
/// for rendering. **Conservative on purpose:** only `http://` / `https://` runs
/// are treated as links, each ending at the first whitespace, with trailing prose
/// punctuation peeled back into the following text. Concatenating every segment's
/// inner string reproduces the input exactly (no characters added or dropped), so
/// it's purely a presentational split. Linkify AFTER sanitizing so control/format
/// chars are already gone (the URL can't smuggle them). Pure → unit-testable.
pub fn linkify(input: &str) -> Vec<Segment> {
    let mut out = Vec::new();
    let mut rest = input;
    while !rest.is_empty() {
        let Some(start) = find_scheme(rest) else {
            out.push(Segment::Text(rest.to_string()));
            break;
        };
        if start > 0 {
            out.push(Segment::Text(rest[..start].to_string()));
        }
        let after = &rest[start..];
        let end = after.find(char::is_whitespace).unwrap_or(after.len());
        let candidate = &after[..end];
        // Peel trailing punctuation back out of the link.
        let url = candidate.trim_end_matches(is_url_trailing_punct);
        out.push(Segment::Link(url.to_string()));
        // Continue past just the URL; any peeled punctuation + the rest (incl. the
        // whitespace) is reconsidered as ordinary text on the next iteration.
        rest = &after[url.len()..];
    }
    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   "), "");
    }

    // --- linkify -----------------------------------------------------------

    /// Concatenating every segment's inner text must reproduce the input exactly.
    fn reassemble(segs: &[Segment]) -> String {
        segs.iter()
            .map(|s| match s {
                Segment::Text(t) | Segment::Link(t) => t.as_str(),
            })
            .collect()
    }

    #[test]
    fn linkify_plain_text_has_no_links() {
        let segs = linkify("just a normal message, nothing here");
        assert_eq!(
            segs,
            vec![Segment::Text("just a normal message, nothing here".into())]
        );
    }

    #[test]
    fn linkify_detects_http_and_https() {
        assert_eq!(
            linkify("see http://example.com now"),
            vec![
                Segment::Text("see ".into()),
                Segment::Link("http://example.com".into()),
                Segment::Text(" now".into()),
            ]
        );
        assert_eq!(
            linkify("https://a.test/path?q=1"),
            vec![Segment::Link("https://a.test/path?q=1".into())]
        );
    }

    #[test]
    fn linkify_peels_trailing_punctuation() {
        // Sentence-final period is not part of the link.
        assert_eq!(
            linkify("go to https://x.com."),
            vec![
                Segment::Text("go to ".into()),
                Segment::Link("https://x.com".into()),
                Segment::Text(".".into()),
            ]
        );
        // Parenthesized URL.
        assert_eq!(
            linkify("(https://x.com)"),
            vec![
                Segment::Text("(".into()),
                Segment::Link("https://x.com".into()),
                Segment::Text(")".into()),
            ]
        );
    }

    #[test]
    fn linkify_handles_multiple_urls() {
        let segs = linkify("a http://one.com b https://two.com c");
        assert_eq!(
            segs,
            vec![
                Segment::Text("a ".into()),
                Segment::Link("http://one.com".into()),
                Segment::Text(" b ".into()),
                Segment::Link("https://two.com".into()),
                Segment::Text(" c".into()),
            ]
        );
    }

    #[test]
    fn linkify_only_matches_http_schemes() {
        // Non-web schemes and bare domains are NOT linkified (conservative).
        let segs = linkify("email me@x.com or ftp://x.com or visit x.com");
        assert_eq!(
            segs,
            vec![Segment::Text(
                "email me@x.com or ftp://x.com or visit x.com".into()
            )]
        );
    }

    #[test]
    fn linkify_preserves_input_exactly() {
        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 世界",
        ] {
            assert_eq!(
                reassemble(&linkify(msg)),
                msg,
                "roundtrip failed for {msg:?}"
            );
        }
    }
}
