package wang.yaojia.webterm.session

import wang.yaojia.webterm.wire.Tunables
import java.text.BreakIterator

/**
 * OSC-title sanitiser (A6; ports iOS `SessionCore.TitleSanitizer`). Terminal
 * titles (OSC 0/2, surfaced by the emulator's title callback) are HOST/ATTACKER-
 * CONTROLLED input — they must pass through here before any list/registry/UI use
 * (plan §7).
 *
 * Rules, in order:
 * 1. Strip ALL Unicode control characters (C0 U+0000-001F + C1 U+0080-009F + DEL
 *    U+007F). The emulator's OSC parsing should already exclude C0, but that is
 *    NOT assumed — escape injection gets zero tolerance at this boundary.
 * 2. Strip the spoofing vectors OSC parsing does NOT touch: zero-width and
 *    bidirectional formatting characters — U+200B-200F (zero-width space /
 *    non-joiner / joiner, LRM/RLM), U+202A-202E (bidi embeddings + the classic
 *    RLO override), U+2066-2069 (bidi isolates), plus U+FEFF (zero-width no-break
 *    space / BOM). Note: stripping U+200D (ZWJ) splits multi-person emoji into
 *    their parts — safety over ligature aesthetics.
 * 3. Trim leading/trailing whitespace (mirrors web `title.trim() || null`,
 *    public/tabs.ts:626 — the caller treats "" as "no title").
 * 4. Truncate to [Tunables.TITLE_MAX_LENGTH] grapheme clusters (an emoji flood
 *    caps at 256 visible glyphs and no glyph is ever split).
 *
 * Pure + idempotent: sanitize(sanitize(x)) == sanitize(x).
 */
public object TitleSanitizer {
    /** Zero-width / bidi code-point ranges stripped on top of the control set. */
    private val strippedRanges: List<IntRange> = listOf(
        0x200B..0x200F, // zero-width space/non-joiner/joiner, LRM, RLM
        0x202A..0x202E, // bidi embeddings, pops and overrides (incl. RLO)
        0x2066..0x2069, // bidi isolates (LRI/RLI/FSI/PDI)
        0xFEFF..0xFEFF, // zero-width no-break space / BOM
    )

    public fun sanitize(raw: String): String {
        val kept = StringBuilder(raw.length)
        var i = 0
        while (i < raw.length) {
            val cp = Character.codePointAt(raw, i)
            if (!isDisallowed(cp)) kept.appendCodePoint(cp)
            i += Character.charCount(cp)
        }
        val trimmed = kept.toString().trim()
        return truncateToGraphemes(trimmed, Tunables.TITLE_MAX_LENGTH)
    }

    private fun isDisallowed(codePoint: Int): Boolean {
        // Character.CONTROL (Cc) covers C0 (U+0000-001F), DEL (U+007F) and C1
        // (U+0080-009F) in one authoritative check — the iOS `.control` analogue.
        if (Character.getType(codePoint) == Character.CONTROL.toInt()) return true
        return strippedRanges.any { codePoint in it }
    }

    /**
     * Keep the first [max] grapheme clusters (extended grapheme boundaries, the
     * Swift `Character` analogue) so an astral glyph or emoji sequence is never
     * split mid-cluster. `max <= 0` yields "".
     */
    private fun truncateToGraphemes(text: String, max: Int): String {
        if (max <= 0) return ""
        if (text.isEmpty()) return text
        val boundaries = BreakIterator.getCharacterInstance()
        boundaries.setText(text)
        var count = 0
        var boundary = boundaries.next()
        while (boundary != BreakIterator.DONE) {
            count++
            if (count == max) return text.substring(0, boundary)
            boundary = boundaries.next()
        }
        return text // fewer than `max` clusters → whole string
    }
}
