# (c) 2026 Applied Brain Research
#
# All information contained herein is and remains the property of Applied Brain
# Research. The intellectual and technical concepts contained herein are
# proprietary to Applied Brain Research and may be covered by U.S. and Foreign
# Patents, patents in process, and are protected by trade secret or copyright
# law. Dissemination of this information or reproduction of this material is
# strictly forbidden unless prior written permission is obtained from Applied
# Brain Research. Access to the source code contained herein is hereby
# forbidden to anyone except current Applied Brain Research employees,
# contractors or other outside parties that have executed Confidentiality
# and/or Non-disclosure agreements explicitly covering such access.
#
# The copyright notice above does not evidence any actual or intended
# publication or disclosure of this source code, which includes information
# that is confidential and/or proprietary, and is a trade secret, of Applied
# Brain Research. ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC
# PERFORMANCE, OR PUBLIC DISPLAY OF OR THROUGH USE OF THIS SOURCE CODE WITHOUT
# THE EXPRESS WRITTEN CONSENT OF APPLIED BRAIN RESEARCH IS STRICTLY PROHIBITED,
# AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES. THE RECEIPT
# OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION DOES NOT CONVEY
# OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS, OR TO
# MANUFACTURE, USE, OR SELL ANYTHING THAT IT MAY DESCRIBE, IN WHOLE OR IN PART.
"""Text preprocessing functions for TTS."""

import re
import sys
import unicodedata
from collections.abc import Callable
from itertools import groupby
from string import ascii_letters
from typing import ClassVar


class NumberToWords:
    """
    Convert numbers to English words.

    Based on the JavaScript implementation in text-preprocessor.js.
    """

    ONES: ClassVar = [
        "",
        "one",
        "two",
        "three",
        "four",
        "five",
        "six",
        "seven",
        "eight",
        "nine",
    ]
    TEENS: ClassVar = [
        "ten",
        "eleven",
        "twelve",
        "thirteen",
        "fourteen",
        "fifteen",
        "sixteen",
        "seventeen",
        "eighteen",
        "nineteen",
    ]
    TENS: ClassVar = [
        "",
        "",
        "twenty",
        "thirty",
        "forty",
        "fifty",
        "sixty",
        "seventy",
        "eighty",
        "ninety",
    ]
    # Cardinal words whose ordinal is not formed by the regular rules in
    # _ordinalize_word (append "th", or "y" -> "ieth"). Everything else --
    # "four" -> "fourth", "hundred" -> "hundredth", "million" -> "millionth",
    # "twenty" -> "twentieth" -- the rules handle, so only the exceptions live
    # here.
    _ORDINAL_WORD: ClassVar = {
        "one": "first",
        "two": "second",
        "three": "third",
        "five": "fifth",
        "eight": "eighth",
        "nine": "ninth",
        "twelve": "twelfth",
    }
    # Short-scale group names, indexed by power of one thousand: SCALES[1] is
    # "thousand" (1000^1), SCALES[2] "million", and so on. Index 0 has no name
    # (the ones group reads bare). A number with more groups than this list has
    # names for is read digit by digit instead (see to_cardinal).
    SCALES: ClassVar = [
        "",
        "thousand",
        "million",
        "billion",
        "trillion",
        "quadrillion",
        "quintillion",
        "sextillion",
        "septillion",
        "octillion",
        "nonillion",
        "decillion",
    ]
    # Smallest value with more thousands-groups than SCALES can name; at or
    # above it, a number is read digit by digit instead of grouped into words.
    DIGIT_FALLBACK_MIN: ClassVar = 1000 ** len(SCALES)

    def _convert_hundreds(self, num: int) -> str:
        """Convert a number (0-999) to words."""
        if num == 0:
            return ""

        result = ""
        hundreds = num // 100
        remainder = num % 100

        if hundreds > 0:
            result += self.ONES[hundreds] + " hundred"
            if remainder > 0:
                result += " "

        if 10 <= remainder < 20:
            result += self.TEENS[remainder - 10]
        else:
            tens_digit = remainder // 10
            ones_digit = remainder % 10

            if tens_digit > 0:
                result += self.TENS[tens_digit]

            if ones_digit > 0:
                if tens_digit > 0:
                    result += " "
                result += self.ONES[ones_digit]

        return result

    def _ordinalize_word(self, word: str) -> str:
        """
        Turn one cardinal word into its ordinal form.

        Applied to the final word of a cardinal reading, which is the only
        word an English ordinal inflects ("forty two" -> "forty second",
        "one hundred" -> "one hundredth", "two thousand" -> "two thousandth").
        """
        if word in self._ORDINAL_WORD:
            return self._ORDINAL_WORD[word]
        if word.endswith("y"):
            return word[:-1] + "ieth"
        return word + "th"

    def _spell_digits(self, digits: str) -> str:
        """Read a digit string one digit at a time (e.g. "905" -> "nine zero five")."""
        return " ".join("zero" if d == "0" else self.ONES[int(d)] for d in digits)

    def _grouped_words(self, num: int) -> list[str]:
        """
        Cardinal words for the thousands-groups of a positive number, high to low.

        Splitting on base 1000 keeps every group in 0-999, which is exactly
        what ``_convert_hundreds`` accepts, so no input magnitude can push a
        group out of range. Groups that are zero contribute nothing.
        """
        groups = []
        scale = 0
        while num > 0:
            groups.append((num % 1000, scale))
            num //= 1000
            scale += 1

        parts = []
        for value, scale_idx in reversed(groups):
            if value == 0:
                continue
            words = self._convert_hundreds(value)
            if scale_idx > 0:
                words += " " + self.SCALES[scale_idx]
            parts.append(words)
        return parts

    def to_cardinal(self, num: int | str) -> str:
        """Convert integer to cardinal words."""
        num = int(num)
        if num == 0:
            return "zero"

        is_negative = num < 0
        num = abs(num)

        if num >= self.DIGIT_FALLBACK_MIN:
            # Past the largest named scale; read it out digit by digit so an
            # arbitrarily large value can never run off the scale table.
            result = self._spell_digits(str(num))
        else:
            result = " ".join(self._grouped_words(num))

        if is_negative:
            result = "negative " + result

        return result

    def to_year(self, num: int | str) -> str:
        """Read a 4-digit integer as a spoken year, not a plain cardinal."""
        num = int(num)
        if num < 1000 or num > 9999:
            # Not four digits; read it as a plain number.
            return self.to_cardinal(num)
        high, low = num // 100, num % 100
        if 2000 <= num <= 2009:
            # "two thousand five", not "twenty oh five".
            return self.to_cardinal(num)
        if low == 0:
            # 1900 -> "nineteen hundred".
            return self.to_cardinal(high) + " hundred"
        if low < 10:
            # 1905 -> "nineteen oh five".
            return self.to_cardinal(high) + " oh " + self.to_cardinal(low)
        # 1776 -> "seventeen seventy six", 2024 -> "twenty twenty four".
        return self.to_cardinal(high) + " " + self.to_cardinal(low)

    def to_ordinal(self, num: int | str) -> str:
        """Convert integer to ordinal words."""
        # An ordinal reads as the cardinal with only its final word inflected,
        # so building on to_cardinal inherits its grouping, sign handling, and
        # digit fallback for free.
        words = self.to_cardinal(num).split()
        words[-1] = self._ordinalize_word(words[-1])
        return " ".join(words)

    def to_decimal(self, num: float | str) -> str:
        """Convert decimal number to words (reads out digits after decimal)."""
        parts = str(num).split(".")
        int_part = int(parts[0])
        dec_part = parts[1] if len(parts) > 1 else ""

        result = self.to_cardinal(int_part) + " point"
        if len(dec_part) > 0:
            result += " " + self._spell_digits(dec_part)

        return result

    def to_currency(self, num: float | str) -> str:
        """Convert currency to words (USD)."""
        num = float(num)
        dollars = int(num)
        cents = round((num - dollars) * 100)

        result = self.to_cardinal(dollars)
        result += " dollar" if dollars == 1 else " dollars"

        if cents > 0:
            result += " and " + self.to_cardinal(cents)
            result += " cent" if cents == 1 else " cents"

        return result


class ItoNormalization:
    """
    Number normalization for TTS.

    Adapted from Keith Ito's Tacotron preprocessing:
    https://github.com/keithito/tacotron/blob/master/text/numbers.py
    """

    def __init__(self) -> None:
        self.num2words = NumberToWords()
        self.substitutes = [
            # Remove commas in numbers
            (re.compile(r"([0-9][0-9,]+[0-9])"), self._remove_commas),
            # Replace currency values (e.g. "$100"->"one hundred dollars")
            (re.compile(r"\$([0-9.,]*[0-9]+)"), self._convert_currency),
            # Replace decimal values (e.g "0.12"->"zero point one two")
            (re.compile(r"([0-9]+\.[0-9]+)"), self._convert_decimal),
            # Replace ordinal values (e.g. "42nd street"->"forty second street")
            # codespell:ignore nd
            (re.compile(r"([0-9]+)(st|nd|rd|th)"), self._convert_ordinal),
            # Replace any remaining numbers (e.g. "326"->"three hundred twenty six")
            (re.compile(r"[0-9]+"), self._convert_cardinal),
        ]

    def _remove_commas(self, match: re.Match) -> str:
        return match.group(0).replace(",", "")

    def _convert_currency(self, match: re.Match) -> str:
        return self.num2words.to_currency(match.group(1).replace(",", ""))

    def _convert_decimal(self, match: re.Match) -> str:
        return self.num2words.to_decimal(match.group(0))

    def _convert_ordinal(self, match: re.Match) -> str:
        return self.num2words.to_ordinal(match.group(1))

    def _convert_cardinal(self, match: re.Match) -> str:
        return self.num2words.to_cardinal(match.group(0))

    def __call__(self, text: str) -> str:
        """Apply all number-normalization substitutions to the text."""
        for pattern, replacement in self.substitutes:
            text = pattern.sub(replacement, text)
        return text


def unicode_normalization(text: str) -> str:
    """
    Normalize unicode characters.

    Handles accents, dashes, ellipsis, parentheses, and other special
    punctuation for better TTS pronunciation.
    """
    # Ensure that characters with accents (e.g. ü, á, ã, etc.) are split into their
    # canonical pieces (e.g ü -> [u, ◌̈ ]), where accents are filtered (e.g. ü -> u).
    # See https://en.wikipedia.org/wiki/Unicode_equivalence#Example
    text = "".join(
        char
        for char in unicodedata.normalize("NFD", text)
        if unicodedata.category(char) != "Mn"
    )

    # Handle dashes and special punctuation for TTS pauses
    # Em-dash (—) creates a pause, replace with comma
    text = text.replace("\u2014", ", ")

    # En-dash (U+2013) in number ranges becomes "to"
    text = re.sub(r"(\d+)\u2013(\d+)", r"\1 to \2", text)

    # Other en-dashes become spaced hyphen
    text = text.replace("\u2013", " - ")

    # Ellipsis creates a pause, replace with commas
    text = text.replace("\u2026", ", , , ")

    # Also handle regular three-dot ellipsis
    text = text.replace("...", ", , , ")

    # Parentheses create a pause, replace with comma
    text = re.sub(r"[()]", ", ", text)

    # Replace colons and semicolons with commas for pauses
    text = re.sub(r"[;:]", ", ", text)

    # Replace degrees symbol with word
    text = text.replace("\u00b0", " degrees ")

    # Convert smart quotes to ASCII equivalents
    text = text.replace("\u2018", "'")  # Left single quote
    text = text.replace("\u2019", "'")  # Right single quote
    text = text.replace("\u201c", '"')  # Left double quote
    return text.replace("\u201d", '"')  # Right double quote


# Characters the model can speak in non-tag text: letters, space, and the
# punctuation it handles. Digits are excluded (spelled out earlier in the
# pipeline); angle brackets are kept for SSML tags.
_SPOKEN_CHARS = frozenset(ascii_letters + " '.,?!<>-")


def remove_special_characters(text: str) -> str:
    """
    Drop characters outside _SPOKEN_CHARS.

    A "/" first becomes a space so a slash-joined pair reads as two words
    ("and/or" -> "and or") instead of running together.
    """
    text = text.replace("/", " ")
    return "".join(c for c in text if c in _SPOKEN_CHARS)


def spell_out_acronyms(text: str) -> str:
    """
    Spell out acronyms for better pronunciation.

    Examples: "VUI" -> "V U I", "VUIs" -> "V U I zz", "API" -> "ay P I"
    """

    def replace_acronym(match: re.Match) -> str:
        acronym = match.group(1)
        plural_s = match.group(2)

        spelled = " ".join("ay" if char == "A" else char for char in acronym)

        return f"{spelled} zz" if plural_s else spelled

    return re.sub(r"\b([A-Z][A-Z0-9]+)(s?)\b", replace_acronym, text)


def expand_abbreviations(text: str) -> str:
    """Expand common abbreviations to words."""
    result = text

    # i.e. and e.g. - keep flexible for with/without periods
    result = re.sub(r"\b(i\.e\.?|ie\.?|I\.E\.?|IE\.?),?\s*", "that is, ", result)
    result = re.sub(r"\b(e\.g\.?|eg\.?|E\.G\.?|EG\.?),?\s*", "for example, ", result)
    result = re.sub(r"\b(etc\.?|ETC\.?|Etc\.?),?\s*", "et cetera, ", result)

    # These MUST have periods to avoid matching inside words
    result = re.sub(r"\b(ave\.|AVE\.|Ave\.),?\s*", "avenue ", result)
    result = re.sub(r"\b(rd\.|RD\.|Rd\.),?\s*", "road ", result)
    result = re.sub(r"\b(st\.|ST\.|St\.),?\s*", "street ", result)
    result = re.sub(r"\b(ste\.|STE\.|Ste\.),?\s*", "suite ", result)
    result = re.sub(
        r"\b(p\.o\.|po\.|P\.O\.|PO\.),?\s*",
        "post office ",
        result,
        flags=re.IGNORECASE,
    )

    # Titles
    result = re.sub(r"\b(dr\.|DR\.|Dr\.)\s*", "doctor ", result)
    result = re.sub(r"\b(mr\.|MR\.|Mr\.)\s*", "mister ", result)
    result = re.sub(r"\b(mrs\.|MRS\.|Mrs\.)\s*", "missus ", result)
    return re.sub(r"\b(ms\.|MS\.|Ms\.)\s*", "miss ", result)


_MONTHS = [
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "September",
    "October",
    "November",
    "December",
]
_NUM2WORDS = NumberToWords()


# Denominators that read more naturally as plain words than as ordinals,
# keyed to their (singular, plural) forms.
_FRACTION_DENOMINATORS = {2: ("half", "halves"), 4: ("quarter", "quarters")}


def _fraction_denominator_word(denominator: int, *, plural: bool) -> str:
    """Spell a fraction denominator (e.g. 4 -> "quarter", 8 plural -> "eighths")."""
    special = _FRACTION_DENOMINATORS.get(denominator)
    if special is not None:
        return special[1] if plural else special[0]
    word = _NUM2WORDS.to_ordinal(denominator)
    return word + "s" if plural else word


# A slash-joined pair of numbers with a trailing year is a date; without one it
# is a fraction. Matching both in a single pattern keeps that either/or decision
# in one place (see _replace_slash_number) rather than spread across two passes.
_SLASH_NUMBER_RE = re.compile(r"\b(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b")


def _replace_slash_number(match: re.Match) -> str:
    year_str = match.group(3)

    if year_str is not None:
        month = int(match.group(1))
        day = int(match.group(2))
        if month < 1 or month > 12 or day < 1 or day > 31:
            return match.group(0)
        month_name = _MONTHS[month - 1]
        day_word = _NUM2WORDS.to_ordinal(day)
        return f"{month_name} {day_word}, {_NUM2WORDS.to_year(year_str)}"

    numerator = int(match.group(1))
    denominator = int(match.group(2))
    if denominator < 2:
        return match.group(0)
    numerator_word = _NUM2WORDS.to_cardinal(numerator)
    denominator_word = _fraction_denominator_word(denominator, plural=numerator != 1)
    return f"{numerator_word} {denominator_word}"


def normalize_slash_numbers(text: str) -> str:
    """
    Convert slash-joined numbers to words.

    A trailing year marks a date ("12/25/2024" -> "December twenty fifth, two
    thousand twenty four"). A bare "N/M" is read as a fraction ("1/2" -> "one
    half"), never as a date, so "1/2 a cup" reads as "one half a cup" rather
    than "January second a cup".
    """
    return _SLASH_NUMBER_RE.sub(_replace_slash_number, text)


def collapse_repeated_punctuation(text: str) -> str:
    """Collapse runs of repeated punctuation (e.g. "!!!!" -> "!", "???" -> "?")."""
    return re.sub(r"([!?.,;:])\1+", r"\1", text)


def collapse_whitespace(text: str) -> str:
    """Collapse multiple spaces/tabs to single space."""
    return re.sub(r"\s+", " ", text)


def add_final_period(text: str) -> str:
    """Ensure text ends with a period."""
    if re.search(r"[.?!]\s*$", text):
        return text
    return text + " ."


def final_cleanup(text: str) -> str:
    """Trim and collapse repeated whitespace to single spaces."""
    return re.sub(r"\s+", " ", text).strip()


# Common British spellings mapped to their American forms. The phoneme
# dictionary is American, so a British spelling misses the lookup and falls
# back to rule-based guessing that mispronounces it (e.g. "favourite"). Keys
# are lowercase base forms; input capitalization is preserved and a trailing
# plural "s" is handled generically. This is a high-frequency starter set, not
# an exhaustive list, and it intentionally omits "-ed"/"-ing" derivatives and
# words whose "+s" form collides with a different word (analyse, paralyse).
_BRITISH_TO_AMERICAN = {
    "colour": "color",
    "favour": "favor",
    "favourite": "favorite",
    "favourable": "favorable",
    "flavour": "flavor",
    "honour": "honor",
    "behaviour": "behavior",
    "neighbour": "neighbor",
    "neighbourhood": "neighborhood",
    "labour": "labor",
    "humour": "humor",
    "rumour": "rumor",
    "vapour": "vapor",
    "odour": "odor",
    "harbour": "harbor",
    "armour": "armor",
    "saviour": "savior",
    "glamour": "glamor",
    "endeavour": "endeavor",
    "parlour": "parlor",
    "centre": "center",
    "theatre": "theater",
    "metre": "meter",
    "litre": "liter",
    "fibre": "fiber",
    "calibre": "caliber",
    "sombre": "somber",
    "spectre": "specter",
    "organise": "organize",
    "organisation": "organization",
    "realise": "realize",
    "recognise": "recognize",
    "apologise": "apologize",
    "emphasise": "emphasize",
    "criticise": "criticize",
    "memorise": "memorize",
    "catalogue": "catalog",
    "dialogue": "dialog",
    "analogue": "analog",
    "defence": "defense",
    "offence": "offense",
    "licence": "license",
    "grey": "gray",
    "mould": "mold",
    "plough": "plow",
    "cheque": "check",
    "programme": "program",
    "aluminium": "aluminum",
    "manoeuvre": "maneuver",
    "pyjamas": "pajamas",
    "tyre": "tire",
    "kerb": "curb",
    "draught": "draft",
    "jewellery": "jewelry",
    "moustache": "mustache",
    "cosy": "cozy",
}


def _americanize_word(word: str) -> str:
    """Map one word to its American spelling, preserving leading capitalization."""
    # An all-uppercase word is an initialism for spell_out_acronyms to spell out
    # ("COLOUR" -> "C O L O U R"); rewriting it here would corrupt that.
    if word.isupper():
        return word

    lower = word.lower()
    american = _BRITISH_TO_AMERICAN.get(lower)
    if american is None and lower.endswith("s"):
        base = _BRITISH_TO_AMERICAN.get(lower[:-1])
        american = None if base is None else base + "s"
    if american is None:
        return word

    return american.capitalize() if word[0].isupper() else american


def normalize_british_spelling(text: str) -> str:
    """Map common British spellings to American (e.g. "favourite" -> "favorite")."""
    # groupby on str.isalpha splits text into maximal letter / non-letter runs,
    # so each letter run is one word to americanize and the punctuation and
    # spacing between words passes through verbatim.
    out = []
    for is_word, chars in groupby(text, key=str.isalpha):
        run = "".join(chars)
        out.append(_americanize_word(run) if is_word else run)
    return "".join(out)


# Normalization stages: turn raw glyphs into speakable words. These run on any
# text, including the spoken runs between SSML tags. They do not add or strip a
# terminal period; that is applied around them only for tag-free input.
_DEFAULT_PIPELINE = [
    normalize_british_spelling,  # favourite -> favorite
    spell_out_acronyms,  # VUI -> V U I
    normalize_slash_numbers,  # 12/25/2024 -> December ...; 1/2 -> one half
    ItoNormalization(),  # numbers / currency / decimals -> words
    unicode_normalization,  # strip accents, fancy chars, dashes
    collapse_repeated_punctuation,  # !!!! -> !,  ??? -> ?
    collapse_whitespace,  # spaces/tabs/newlines -> single space
    remove_special_characters,  # drop chars outside the model alphabet
    expand_abbreviations,  # Dr. -> doctor, etc.
]

# Tag matcher: the capturing group makes re.split keep the tags, so the result
# alternates text, tag, text, tag, ...: even indices are spoken text, odd
# indices are tags. The tag name may be namespaced (abr:emotion) and carry
# attributes.
_SSML_TAG_RE = re.compile(r"(</?[a-zA-Z][a-zA-Z0-9:]*(?:\s[^>]*)?>)")

# Tags and their permitted attributes, mirrored from the SSML parser in the C
# backend (abr-tfs/src/tfs_ssml.c). Kept here only to catch structural problems
# (unknown tag, unknown attribute, unquoted value) before bytes reach libexpat,
# which aborts the whole utterance on a single malformed tag. The meaning of
# attribute *values* (rate presets, "+2st", "-6dB", ...) is not duplicated; the
# C parser stays the single source of truth and warns on unsupported values.
_SSML_TAGS: dict[str, set[str]] = {
    "speak": set(),
    "prosody": {"rate", "pitch", "volume"},
    "abr:emotion": {"type"},
}


def _ssml_split_tag(tag: str) -> tuple[bool, str, str]:
    """
    Split a tag like ``<prosody rate="slow">`` into (is_closing, name, attrs).

    The tag always has a name: the matcher that produced it requires one.
    """
    inner = tag[1:-1].strip()  # drop the surrounding < >
    is_closing = inner.startswith("/")
    if is_closing:
        inner = inner[1:].strip()
    parts = inner.split(None, 1)
    name = parts[0]
    attrs = parts[1] if len(parts) == 2 else ""
    return is_closing, name, attrs


def _ssml_tag_error(tag: str) -> str | None:
    """
    Check one SSML tag for structural problems.

    Returns a human-readable reason if the tag should be dropped (unknown tag,
    unknown attribute, or an unquoted/unterminated attribute value), or ``None``
    if it is well-formed and known. Attribute values must be wrapped in matching
    quotes, the same rule libexpat enforces; ``rate=fast`` is rejected here so it
    never reaches the backend and halts playback.
    """
    is_closing, name, attrs = _ssml_split_tag(tag)

    if name not in _SSML_TAGS:
        return f"unknown tag '{name}'"
    if is_closing:
        if len(attrs.strip()) > 0:
            return f"closing tag '{name}' must not carry attributes"
        return None

    allowed = _SSML_TAGS[name]
    i = 0
    while i < len(attrs):
        while i < len(attrs) and attrs[i].isspace():
            i += 1
        if i >= len(attrs):
            break
        key_start = i
        while i < len(attrs) and not attrs[i].isspace() and attrs[i] != "=":
            i += 1
        key = attrs[key_start:i]
        while i < len(attrs) and attrs[i].isspace():
            i += 1
        if i >= len(attrs) or attrs[i] != "=":
            return f"attribute '{key}' is missing a value"
        i += 1  # consume '='
        while i < len(attrs) and attrs[i].isspace():
            i += 1
        if i >= len(attrs) or attrs[i] not in "\"'":
            return f"value for attribute '{key}' must be quoted"
        quote = attrs[i]
        i += 1
        while i < len(attrs) and attrs[i] != quote:
            i += 1
        if i >= len(attrs):
            return f"value for attribute '{key}' is missing its closing quote"
        i += 1  # consume the closing quote
        if key not in allowed:
            return f"tag '{name}' has no attribute '{key}'"
    return None


def find_unpreprocessed_chars(text: str) -> set[str]:
    """
    Return the spoken characters that tts_preprocess would have removed.

    Inspects only the runs between SSML tags, so tag syntax (``=``, quotes,
    digits in attribute values) is not flagged; an empty result means the text
    looks preprocessed. Assumes whole tags: a tag split mid-stream may flag its
    internals.
    """
    spoken = "".join(_SSML_TAG_RE.split(text)[::2])
    return {c for c in spoken if c not in _SPOKEN_CHARS}


def tts_preprocess(text: str, *, pipeline: list[Callable] | None = None) -> str:
    """
    Preprocess text for TTS synthesis.

    Normalizes raw text into a form the TTS phoneme dictionary can pronounce:
    spells out acronyms, turns numbers/dates/currency into words, strips accents
    and fancy punctuation, and expands abbreviations. Sentence-ending ``.`` ``?``
    ``!`` the caller wrote are kept (the model renders each with distinct
    prosody).

    Well-formed SSML markup (``<prosody>``, ``</speak>``, ``<abr:emotion>``,
    ...) passes through untouched: the input is split on tags and only the
    spoken runs between them are normalized. Normalizing the whole string would
    strip the ``=`` ``"`` ``/`` and attribute values the tags need to stay
    well-formed.

    Structurally malformed tags (unquoted value like ``rate=fast``, unknown tag,
    unknown attribute) are dropped with a warning printed to stdout, keeping the
    surrounding text. A single malformed tag would otherwise abort the whole
    utterance inside the backend's XML parser. When a malformed *opening* tag is
    dropped, its matching close (tracked by a LIFO name stack) is dropped too, so
    nesting stays balanced; an unmatched closing tag is left alone, since in a
    streamed push its opening tag may have arrived in an earlier call.

    Tag-free text gets a trailing ``.`` when it has no sentence-ending
    punctuation, so libtfs has a terminal to flush on. Tagged input gets no
    such synthetic terminator: its runs may be one sentence split across tags,
    or only part of a sentence in a streaming push, so a period between them
    would be wrong; the caller's own punctuation, a ``</speak>``, or the flush
    byte ends it.
    """
    pipeline = _DEFAULT_PIPELINE if pipeline is None else pipeline

    def normalize(segment: str) -> str:
        for fn in pipeline:
            segment = fn(segment)
        return segment

    segments = _SSML_TAG_RE.split(text)
    if len(segments) == 1:
        # No SSML tags: normalize, then ensure a single trailing terminator.
        return final_cleanup(add_final_period(final_cleanup(normalize(text))))

    # Tagged input: normalize the spoken-text runs (even indices) and keep the
    # well-formed tags (odd indices) verbatim. Malformed tags are dropped with a
    # warning so they cannot abort synthesis in the backend, and a dropped open
    # takes its matching close with it so nesting stays balanced. No period is
    # inserted between or after the runs, and the spaces around tags are intact.
    out = []
    open_stack: list[tuple[str, bool]] = []  # (tag name, kept?) for each open
    for i, segment in enumerate(segments):
        if i % 2 == 0:
            out.append(normalize(segment))
            continue
        is_closing, name, _ = _ssml_split_tag(segment)
        if not is_closing:
            error = _ssml_tag_error(segment)
            kept = error is None
            if not kept:
                # NB: keep this stderr so it never corrupts PCM piped to stdout.
                print(f"SSML warning: {error}; ignoring tag {segment}", file=sys.stderr)
            open_stack.append((name, kept))
            if kept:
                out.append(segment)
        elif len(open_stack) > 0 and open_stack[-1][0] == name:
            # Pair this close with the most recent matching open; drop it only
            # when that open was itself dropped, otherwise emit it.
            if open_stack.pop()[1]:
                out.append(segment)
        else:
            # No matching open in this call: the open may have been streamed in
            # an earlier push, so pass the close through and let the backend's
            # recovery handle a genuine stray.
            out.append(segment)
    return "".join(out)
