"""Character-level text hygiene for DataFrames.

Operations are independently toggleable, idempotent, and safe to compose.
Each per-string helper is ``str -> str``. Numeric, datetime, and boolean
columns pass through ``clean_dataframe`` untouched; only string cells are
modified.

See TECHNICAL.md Section 10.2 for the full functional spec.
"""

from __future__ import annotations

import json
import re
import unicodedata
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Callable, Iterable, Literal, Optional

import pandas as pd
from loguru import logger
from pandas.api import types as pdtypes


# ---------------------------------------------------------------------------
# Per-string helpers
# ---------------------------------------------------------------------------

# Smart-character map (curly quotes, dashes, ellipsis, NBSP, narrow NBSP).
_SMART_CHARS: dict[str, str] = {
    "‘": "'",   # LEFT SINGLE QUOTATION MARK
    "’": "'",   # RIGHT SINGLE QUOTATION MARK
    "‚": "'",   # SINGLE LOW-9 QUOTATION MARK
    "‛": "'",   # SINGLE HIGH-REVERSED-9 QUOTATION MARK
    "“": '"',   # LEFT DOUBLE QUOTATION MARK
    "”": '"',   # RIGHT DOUBLE QUOTATION MARK
    "„": '"',   # DOUBLE LOW-9 QUOTATION MARK
    "‟": '"',   # DOUBLE HIGH-REVERSED-9 QUOTATION MARK
    "–": "-",   # EN DASH
    "—": "-",   # EM DASH
    "―": "-",   # HORIZONTAL BAR
    "−": "-",   # MINUS SIGN
    "…": "...", # HORIZONTAL ELLIPSIS
    "′": "'",   # PRIME (foot / minute marker)
    "″": '"',   # DOUBLE PRIME (inch / second marker)
    "«": '"',   # LEFT-POINTING DOUBLE ANGLE QUOTATION MARK
    "»": '"',   # RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
    " ": " ",   # NO-BREAK SPACE
    " ": " ",   # NARROW NO-BREAK SPACE
    " ": " ",   # THIN SPACE
    " ": " ",   # HAIR SPACE
    " ": " ",   # EN SPACE
    " ": " ",   # EM SPACE
    "　": " ",   # IDEOGRAPHIC SPACE
}

_SMART_TRANS = str.maketrans(_SMART_CHARS)

# Zero-width / invisible characters. ``U+FEFF`` (BOM/ZWNBSP) is included; if
# it appears at the *very start* of the first cell of the first column, the
# BOM-strip op handles it; elsewhere it is treated as a zero-width char.
_ZERO_WIDTH = (
    "​"  # ZERO WIDTH SPACE
    "‌"  # ZERO WIDTH NON-JOINER
    "‍"  # ZERO WIDTH JOINER
    "⁠"  # WORD JOINER
    "‎"  # LEFT-TO-RIGHT MARK
    "‏"  # RIGHT-TO-LEFT MARK
    "﻿"  # ZERO WIDTH NO-BREAK SPACE / BOM
    "­"  # SOFT HYPHEN
)
_ZERO_WIDTH_RE = re.compile(f"[{_ZERO_WIDTH}]")

# Control characters: U+0000-U+001F and U+007F, but preserve \t \n \r.
_CONTROL_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]")

# Any run of *horizontal* whitespace (spaces, tabs, form/vertical feeds).
# Newlines and carriage returns are excluded so multi-line cells keep their
# line structure; the line-ending op normalizes the actual line terminators.
_WHITESPACE_RUN_RE = re.compile(r"[^\S\n\r]+")


def trim(s: str) -> str:
    """Strip leading/trailing whitespace."""
    if not isinstance(s, str):
        return s
    return s.strip()


def collapse_whitespace(s: str) -> str:
    """Collapse runs of whitespace to a single space.

    Preserves leading/trailing whitespace boundaries (use ``trim`` to remove
    them). Tabs and other whitespace inside the string become a single
    regular space.

    This is the *raw* operation — it always collapses. The cell-level
    pipeline uses :func:`_smart_collapse_whitespace` instead, which skips
    cells that look structured (numeric, dated, or phone-shaped) per
    TEST-CASES.md §4.17.
    """
    if not isinstance(s, str):
        return s
    return _WHITESPACE_RUN_RE.sub(" ", s)


# Cell-shape predicates used to skip ``collapse_whitespace`` on values that
# carry meaningful internal whitespace (European thousands separators,
# phone formatting, dates with space-separated tokens).

# Numeric: optional sign / currency, digits with optional thousand-grouping
# by comma, dot, or single space, and optional decimal portion.
_NUMERIC_SHAPED = re.compile(
    r"^\s*[$€£¥]?\s*[+-]?\d{1,3}(?:[, ]\d{3})+(?:[.,]\d+)?\s*$"
    r"|^\s*[$€£¥]?\s*[+-]?\d+(?:[.,]\d+)?\s*$"
)
# Date: ISO, slash, or dot separators with two- or four-digit year, plus
# the ``Mon DD YYYY`` / ``DD Mon YYYY`` shapes.
_DATE_SHAPED = re.compile(
    r"^\s*\d{1,4}[-/.]\d{1,2}[-/.]\d{1,4}\s*$"
    r"|^\s*\d{1,2}\s+[A-Za-z]{3,9}\s+\d{2,4}\s*$"
    r"|^\s*[A-Za-z]{3,9}\s+\d{1,2}\s+\d{2,4}\s*$"
)
# Phone: a string that's mostly digits with parens / dots / dashes / +
# / spaces, contains at least 7 digits total, and has no letters.
_PHONE_DIGIT_RE = re.compile(r"\d")
_PHONE_NON_DIGIT_RE = re.compile(r"[A-Za-z]")
_PHONE_ALLOWED_RE = re.compile(r"^[\d\s().+\-]+$")


def _looks_structured(s: str) -> bool:
    """True when *s* looks numeric, dated, or phone-shaped.

    Used by the pipeline-level collapse to leave meaningful internal
    whitespace alone (``1 234`` European thousand-sep, ``(555) 123-4567``
    phone formatting, ``Jan 15 2024`` date, etc.). Conservative on purpose:
    a false negative just means the cell gets collapsed (the existing
    behavior); a false positive leaves intentional double spaces in free
    text, which is a worse outcome.
    """
    if not s or not isinstance(s, str):
        return False
    stripped = s.strip()
    if not stripped:
        return False
    if _NUMERIC_SHAPED.match(stripped) or _DATE_SHAPED.match(stripped):
        return True
    if (
        _PHONE_ALLOWED_RE.match(stripped)
        and not _PHONE_NON_DIGIT_RE.search(stripped)
        and len(_PHONE_DIGIT_RE.findall(stripped)) >= 7
    ):
        return True
    return False


def _smart_collapse_whitespace(s: str) -> str:
    """Pipeline variant of :func:`collapse_whitespace` that skips structured cells."""
    if not isinstance(s, str):
        return s
    if _looks_structured(s):
        return s
    return _WHITESPACE_RUN_RE.sub(" ", s)


def to_nfc(s: str) -> str:
    """Apply Unicode NFC (canonical composition)."""
    if not isinstance(s, str):
        return s
    return unicodedata.normalize("NFC", s)


def to_nfkc(s: str) -> str:
    """Apply Unicode NFKC (compatibility composition). Lossy."""
    if not isinstance(s, str):
        return s
    return unicodedata.normalize("NFKC", s)


def fold_smart_chars(s: str) -> str:
    """Fold curly quotes, em/en-dashes, ellipsis, NBSP variants to ASCII."""
    if not isinstance(s, str):
        return s
    return s.translate(_SMART_TRANS)


def strip_zero_width(s: str) -> str:
    """Remove zero-width and bidi-mark characters."""
    if not isinstance(s, str):
        return s
    return _ZERO_WIDTH_RE.sub("", s)


def strip_bom(s: str) -> str:
    """Remove a leading ``U+FEFF`` (BOM) from the start of the string.

    Strips at most one BOM — multiple consecutive BOMs are unusual and
    the second one likely indicates concatenation artifact the caller
    should preserve so the issue stays visible.
    """
    if not isinstance(s, str):
        return s
    return s[1:] if s.startswith("﻿") else s


def strip_control(s: str) -> str:
    """Remove control characters except ``\\t \\n \\r``."""
    if not isinstance(s, str):
        return s
    return _CONTROL_RE.sub("", s)


def normalize_line_endings(s: str) -> str:
    """Normalize ``\\r\\n`` and bare ``\\r`` to ``\\n``."""
    if not isinstance(s, str):
        return s
    return s.replace("\r\n", "\n").replace("\r", "\n")


# Smart title-case helpers
_TITLE_LOWERCASE_PARTICLES = {
    "a", "an", "and", "as", "at", "but", "by", "en", "for", "if", "in", "nor",
    "of", "on", "or", "per", "the", "to", "v", "v.", "vs", "vs.", "via",
}


def _is_all_caps_token(token: str) -> bool:
    """A token is all-caps when it has at least one cased char and no lowercase."""
    has_letter = any(c.isalpha() for c in token)
    has_lower = any(c.islower() for c in token)
    return has_letter and not has_lower and len(token) >= 2


def smart_title_case(s: str) -> str:
    """Title-case that preserves all-caps tokens and lowercases mid-string particles.

    - ``USA`` stays ``USA`` when surrounded by mixed-case words (acronym).
    - ``ALICE SMITH`` becomes ``Alice Smith`` (entire string is shouting).
    - ``of``, ``and``, ``the``, etc. stay lowercase except as the first/last word.
    - Apostrophes inside words don't restart capitalization (``O'Neil``).
    """
    if not isinstance(s, str) or not s:
        return s
    tokens = s.split(" ")
    # If every cased token is all-caps, treat the whole string as SHOUT and
    # title-case it. Otherwise preserve all-caps tokens as acronyms.
    cased_tokens = [t for t in tokens if any(c.isalpha() for c in t)]
    all_shouting = bool(cased_tokens) and all(
        not any(c.islower() for c in t) for t in cased_tokens
    )
    out: list[str] = []
    last_idx = len(tokens) - 1
    for i, tok in enumerate(tokens):
        if not tok:
            out.append(tok)
            continue
        if not all_shouting and _is_all_caps_token(tok):
            out.append(tok)
            continue
        lowered = tok.lower()
        # Particles stay lowercase only mid-string. The first and last
        # words of a title always capitalize, even when they're particles
        # (``A Story to Tell`` — first word ``A`` is capitalized).
        if 0 < i < last_idx and lowered in _TITLE_LOWERCASE_PARTICLES:
            out.append(lowered)
            continue
        # Capitalize first cased character. Inside a token, preserve the
        # original capitalization of the letter immediately after an
        # apostrophe so name patterns like ``O'Connor``/``D'Angelo`` survive
        # while ``o'neil`` -> ``O'neil`` stays lowercase.
        chars = list(tok)
        capitalized = False
        for j, c in enumerate(chars):
            if c.isalpha():
                if not capitalized:
                    chars[j] = c.upper()
                    capitalized = True
                else:
                    prev = chars[j - 1] if j > 0 else ""
                    if prev == "'" and c.isupper():
                        # Preserve original uppercase after apostrophe.
                        pass
                    else:
                        chars[j] = c.lower()
        out.append("".join(chars))
    return " ".join(out)


def sentence_case(s: str) -> str:
    """Lowercase, then capitalize the first cased letter after each ``. ! ?``.

    Non-letter, non-terminator characters (like opening quotes or
    parens) don't consume the "next letter" trigger, so ``"hello." "world"``
    becomes ``"Hello." "World"``.
    """
    if not isinstance(s, str) or not s:
        return s
    lowered = s.lower()
    chars = list(lowered)
    capitalize_next = True
    for i, c in enumerate(chars):
        if c in ".!?":
            capitalize_next = True
            continue
        if capitalize_next and c.isalpha():
            chars[i] = c.upper()
            capitalize_next = False
    return "".join(chars)


CaseMode = Literal["upper", "lower", "title", "sentence"]


def apply_case(s: str, mode: CaseMode) -> str:
    if not isinstance(s, str):
        return s
    if mode == "upper":
        return s.upper()
    if mode == "lower":
        return s.lower()
    if mode == "title":
        return smart_title_case(s)
    if mode == "sentence":
        return sentence_case(s)
    raise ValueError(f"Unknown case mode: {mode}")


# ---------------------------------------------------------------------------
# Options / result dataclasses
# ---------------------------------------------------------------------------

PRESETS: dict[str, dict[str, Any]] = {
    "minimal": {
        "trim": True,
        "collapse_whitespace": True,
        "nfc": False,
        "nfkc": False,
        "fold_smart_chars": False,
        "strip_zero_width": False,
        "strip_bom": False,
        "strip_control": False,
        "normalize_line_endings": False,
    },
    "excel-hygiene": {
        "trim": True,
        "collapse_whitespace": True,
        "nfc": True,
        "nfkc": False,
        "fold_smart_chars": True,
        "strip_zero_width": True,
        "strip_bom": True,
        "strip_control": True,
        "normalize_line_endings": True,
    },
    "paranoid": {
        "trim": True,
        "collapse_whitespace": True,
        "nfc": True,
        "nfkc": True,
        "fold_smart_chars": True,
        "strip_zero_width": True,
        "strip_bom": True,
        "strip_control": True,
        "normalize_line_endings": True,
    },
}


@dataclass
class CleanOptions:
    """Toggles for character-level cleaning operations.

    Defaults match the ``excel-hygiene`` preset.
    """

    # Operations
    trim: bool = True
    collapse_whitespace: bool = True
    nfc: bool = True
    nfkc: bool = False
    fold_smart_chars: bool = True
    strip_zero_width: bool = True
    strip_bom: bool = True
    strip_control: bool = True
    normalize_line_endings: bool = True

    # Apply the same character-level pipeline to column headers. Headers carry
    # the same pollution as data cells (NBSP padding, smart quotes, ZWSP);
    # not cleaning them silently breaks df["col"] lookups downstream.
    clean_headers: bool = True

    # Case conversion: either a single mode applied to all selected columns,
    # or a dict mapping column name -> mode for per-column control.
    case: Optional[CaseMode] = None
    case_columns: dict[str, CaseMode] = field(default_factory=dict)

    # Scope control
    columns: Optional[list[str]] = None  # None = all string-typed columns
    skip_columns: list[str] = field(default_factory=list)

    @classmethod
    def from_preset(cls, name: str) -> CleanOptions:
        if name not in PRESETS:
            raise ValueError(
                f"Unknown preset '{name}'. "
                f"Available: {', '.join(sorted(PRESETS))}."
            )
        return cls(**PRESETS[name])

    @classmethod
    def from_dict(cls, data: dict) -> CleanOptions:
        known = {f for f in cls.__dataclass_fields__}
        kwargs = {k: v for k, v in data.items() if k in known}
        return cls(**kwargs)

    def to_dict(self) -> dict:
        return asdict(self)

    def to_file(self, path: str | Path) -> Path:
        out = Path(path)
        out.write_text(json.dumps(self.to_dict(), indent=2))
        return out

    @classmethod
    def from_file(cls, path: str | Path) -> CleanOptions:
        return cls.from_dict(json.loads(Path(path).read_text()))


@dataclass
class CleanResult:
    """Output of ``clean_dataframe``."""

    cleaned_df: pd.DataFrame
    changes: pd.DataFrame  # cols: row, column, old, new, ops_applied
    cells_changed: int
    cells_total: int
    columns_processed: list[str]


# ---------------------------------------------------------------------------
# Cell-level pipeline
# ---------------------------------------------------------------------------

def _build_pipeline(options: CleanOptions) -> list[tuple[str, Callable[[str], str]]]:
    """Return ordered (op_name, fn) pairs for the cell-level pipeline.

    Order is meaningful:
      1. BOM strip first so a leading FEFF doesn't survive into other ops.
      2. Line-ending normalize before whitespace ops so \\r\\n collapses cleanly.
      3. Control-char strip before whitespace ops.
      4. Smart-char fold before NFC/NFKC (folded ASCII is already NFC-stable).
      5. NFC then NFKC (NFKC subsumes NFC if both set; we still run NFC first
         so the result is identical to NFKC alone — kept explicit for logging).
      6. Zero-width strip after Unicode normalization (NFKC can introduce
         decomposed forms whose combining marks must not be stripped).
      7. Whitespace collapse, then trim, last.
    """
    ops: list[tuple[str, Callable[[str], str]]] = []
    if options.strip_bom:
        ops.append(("strip_bom", strip_bom))
    if options.normalize_line_endings:
        ops.append(("normalize_line_endings", normalize_line_endings))
    if options.strip_control:
        ops.append(("strip_control", strip_control))
    if options.fold_smart_chars:
        ops.append(("fold_smart_chars", fold_smart_chars))
    if options.nfc:
        ops.append(("nfc", to_nfc))
    if options.nfkc:
        ops.append(("nfkc", to_nfkc))
    if options.strip_zero_width:
        ops.append(("strip_zero_width", strip_zero_width))
    if options.collapse_whitespace:
        # The pipeline uses the structured-cell-aware variant so phone /
        # date / numeric cells keep their meaningful internal whitespace.
        ops.append(("collapse_whitespace", _smart_collapse_whitespace))
    if options.trim:
        ops.append(("trim", trim))
    return ops


def _apply_pipeline(
    value: str,
    pipeline: list[tuple[str, Callable[[str], str]]],
) -> tuple[str, list[str]]:
    """Walk a pre-built pipeline over one string. The hot inner step.

    Split out from :func:`clean_value` so the DataFrame loop in
    :func:`clean_dataframe` can build the pipeline once and reuse it
    across millions of cells, instead of rebuilding it per call.
    """
    cur = value
    applied: list[str] = []
    for name, fn in pipeline:
        new = fn(cur)
        if new != cur:
            applied.append(name)
            cur = new
    return cur, applied


def clean_value(value: Any, options: CleanOptions) -> tuple[Any, list[str]]:
    """Apply the configured pipeline to a single cell.

    Returns ``(cleaned_value, ops_applied)``. Non-strings and missing values
    pass through unchanged with an empty ``ops_applied`` list.
    """
    if value is None or (isinstance(value, float) and pd.isna(value)):
        return value, []
    if not isinstance(value, str):
        return value, []

    return _apply_pipeline(value, _build_pipeline(options))


# ---------------------------------------------------------------------------
# DataFrame-level entry point
# ---------------------------------------------------------------------------

def _select_columns(df: pd.DataFrame, options: CleanOptions) -> list[str]:
    """Pick the columns the pipeline should operate on.

    - If ``options.columns`` is explicit, use it (after validating).
    - Otherwise default to columns whose pandas dtype is object/string.
    - Always exclude ``options.skip_columns``.
    """
    if options.columns is not None:
        missing = [c for c in options.columns if c not in df.columns]
        if missing:
            raise ValueError(
                f"Columns not found in input: {missing}. "
                f"Available: {list(df.columns)}"
            )
        chosen: Iterable[str] = options.columns
    else:
        chosen = [
            c for c in df.columns
            if pdtypes.is_object_dtype(df[c]) or pdtypes.is_string_dtype(df[c])
        ]

    skip = set(options.skip_columns)
    return [c for c in chosen if c not in skip]


def clean_dataframe(df: pd.DataFrame, options: Optional[CleanOptions] = None) -> CleanResult:
    """Apply text-cleaning ops to selected columns of *df*.

    Numeric, datetime, and boolean columns are skipped by default. The input
    DataFrame is not mutated; a copy is returned in ``CleanResult.cleaned_df``.

    Pipeline placement (recommended, not enforced)
    ----------------------------------------------
    *Best run early.* Smart-quote, NBSP, and zero-width pollution
    silently breaks downstream parsers — phone numbers fail on
    smart-quote contamination, sentinel detection misses NBSP-padded
    cells, and fuzzy dedup treats whitespace-padded values as
    different. Running this tool before format / missing / dedup is
    the standard order. See ``src.core.pipeline.SOFT_DEPENDENCIES``.
    """
    from .errors import ensure_dataframe
    ensure_dataframe(df, function="clean_dataframe")
    options = options or CleanOptions()
    logger.debug(
        "clean_dataframe: rows={}, cols={}, case={}",
        len(df), len(df.columns), options.case,
    )
    out = df.copy()
    columns = _select_columns(out, options)

    # Hoist the pipeline build out of the per-cell loop. Previously
    # ``clean_value`` rebuilt the (op_name, fn) list on every cell — at
    # 10M cells that's 10M wasted list constructions. Building it once
    # and walking it inline saves a measurable chunk of CPU on large
    # files and keeps memory flat (no growing closures per call).
    pipeline = _build_pipeline(options)

    if options.clean_headers:
        new_columns = [_apply_pipeline(c, pipeline)[0] for c in out.columns]
        if new_columns != list(out.columns):
            # Track column mapping so case_columns/columns/skip_columns based
            # on the original (dirty) names continue to work after rename.
            rename = dict(zip(out.columns, new_columns))
            columns = [rename.get(c, c) for c in columns]
            out.columns = new_columns

    case_per_col: dict[str, CaseMode] = dict(options.case_columns)
    if options.case is not None:
        for c in columns:
            case_per_col.setdefault(c, options.case)

    change_records: list[dict[str, Any]] = []
    cells_changed = 0
    cells_total = 0

    # Per-call cache of clean results, keyed by the raw cell string.
    # Most real-world columns repeat: state codes, country names, status
    # enums, sentinel-laden numerics, blank cells. Caching lets a 1M-row
    # column with 200 unique values run the pipeline 200 times instead
    # of 1M times.
    str_cache: dict[str, tuple[str, tuple[str, ...]]] = {}

    for col in columns:
        series = out[col]
        col_case = case_per_col.get(col)
        values = series.tolist()
        cells_total += len(values)
        new_values: list[Any] = [None] * len(values)

        for row_idx, original in enumerate(values):
            if isinstance(original, str):
                cached = str_cache.get(original)
                if cached is None:
                    c_val, c_ops = _apply_pipeline(original, pipeline)
                    cached = (c_val, tuple(c_ops))
                    str_cache[original] = cached
                cleaned, ops_tuple = cached
                ops_applied = list(ops_tuple)
            else:
                cleaned, ops_applied = original, []

            if col_case is not None and isinstance(cleaned, str):
                cased = apply_case(cleaned, col_case)
                if cased != cleaned:
                    ops_applied.append(f"case:{col_case}")
                    cleaned = cased

            if ops_applied and cleaned != original:
                cells_changed += 1
                change_records.append({
                    "row": row_idx,
                    "column": col,
                    "old": original,
                    "new": cleaned,
                    "ops_applied": ",".join(ops_applied),
                })
            new_values[row_idx] = cleaned
        out[col] = new_values

    changes_df = pd.DataFrame(
        change_records,
        columns=["row", "column", "old", "new", "ops_applied"],
    )

    return CleanResult(
        cleaned_df=out,
        changes=changes_df,
        cells_changed=cells_changed,
        cells_total=cells_total,
        columns_processed=columns,
    )


# ---------------------------------------------------------------------------
# Hidden-character visualization
# ---------------------------------------------------------------------------
#
# The whole point of the cleaner is to remove characters the user can't see.
# That makes the GUI's "before / after" view nearly useless by default — a
# cell with NBSP padding looks identical to a cell with regular spaces. The
# visualizers below render those characters with visible glyphs (and
# tooltips, in the HTML variant) so the user can actually verify what the
# cleaner is doing.
#
# Mapping rule: every character that's normally invisible OR that the
# cleaner treats specially gets a glyph + label. ASCII printable characters
# pass through untouched. Tabs and newlines are visualized because they're
# whitespace the user often wants to see.

# Unicode codepoint -> (visible glyph, short label). The label appears as a
# tooltip in the HTML variant and as ``[label]`` in the text variant.
_VISIBLE_CHAR_MAP: dict[str, tuple[str, str]] = {
    # Whitespace
    "\t":     ("→",  "TAB"),
    "\n":     ("↵",  "LF"),
    "\r":     ("␍",  "CR"),
    " ": ("·",  "NBSP"),
    " ": ("·",  "NNBSP"),
    " ": ("·",  "THIN SP"),
    " ": ("·",  "HAIR SP"),
    " ": ("·",  "EN SP"),
    " ": ("·",  "EM SP"),
    "　": ("·",  "IDEO SP"),
    # Zero-width / invisible
    "​": ("∅",  "ZWSP"),
    "‌": ("∅",  "ZWNJ"),
    "‍": ("∅",  "ZWJ"),
    "⁠": ("∅",  "WJ"),
    "‎": ("⮕",  "LRM"),
    "‏": ("⬅",  "RLM"),
    "﻿": ("∅",  "BOM"),
    "­": ("-",  "SHY"),
    # Smart quotes / dashes / ellipsis (visible but the user often wants
    # them flagged because they're being folded)
    "‘": ("'",  "L QUOTE"),
    "’": ("'",  "R QUOTE"),
    "“": ('"',  "L DQUOTE"),
    "”": ('"',  "R DQUOTE"),
    "—": ("—",  "EM DASH"),
    "–": ("–",  "EN DASH"),
    "…": ("…",  "ELLIPSIS"),
    "′": ("′",  "PRIME"),
    "″": ("″",  "DPRIME"),
    "«": ("«",  "L GUILL"),
    "»": ("»",  "R GUILL"),
}


def _is_control(ch: str) -> bool:
    """C0 control char (other than \\t \\n \\r) or DEL."""
    cp = ord(ch)
    if cp == 0x7F:
        return True
    if cp < 0x20 and ch not in ("\t", "\n", "\r"):
        return True
    return False


def visualize_hidden_text(s: str) -> str:
    """Return a plain-text rendering of *s* with hidden characters labelled.

    Each invisible/control/smart character is replaced by its glyph from
    ``_VISIBLE_CHAR_MAP`` followed by ``[LABEL]``; ASCII printable
    characters pass through untouched. C0 control characters not in the
    map are rendered as ``[U+XXXX]``.

    Suitable for terminal output, CSV exports, and any context where HTML
    is not appropriate.
    """
    if not isinstance(s, str):
        return s
    out: list[str] = []
    for ch in s:
        mapped = _VISIBLE_CHAR_MAP.get(ch)
        if mapped is not None:
            glyph, label = mapped
            out.append(f"{glyph}[{label}]")
        elif _is_control(ch):
            out.append(f"[U+{ord(ch):04X}]")
        else:
            out.append(ch)
    return "".join(out)


def visualize_hidden_html(s: str, *, mark_outer_whitespace: bool = False) -> str:
    """Return an HTML rendering of *s* with hidden characters highlighted.

    Each invisible/control/smart character is wrapped in a ``<span>`` with
    a CSS class and a ``title`` attribute showing the codepoint and label,
    so the user gets a tooltip on hover. ASCII printable text is HTML-
    escaped but otherwise left as-is.

    When *mark_outer_whitespace* is True, leading and trailing runs of
    plain ASCII space and tab are also wrapped in highlight spans. This
    is essential for analyzer/audit views where browsers would otherwise
    silently collapse the leading/trailing space and the user would never
    see the padding the cleaner is going to strip.

    Pair with :func:`hidden_char_css` to inject the matching styles into
    the page.
    """
    if not isinstance(s, str):
        return s  # mirror visualize_hidden_text: pass non-strings through

    leading = ""
    trailing = ""
    body = s
    if mark_outer_whitespace and s:
        i = 0
        while i < len(body) and body[i] in (" ", "\t"):
            i += 1
        leading = body[:i]
        body = body[i:]
        j = len(body)
        while j > 0 and body[j - 1] in (" ", "\t"):
            j -= 1
        trailing = body[j:]
        body = body[:j]

    def _render_outer(run: str, label: str) -> str:
        if not run:
            return ""
        # Each character rendered as a discrete badge so the user sees the
        # exact count of leading/trailing chars, not a single fused block.
        out: list[str] = []
        for ch in run:
            glyph = "→" if ch == "\t" else "·"
            char_label = "TAB" if ch == "\t" else f"SP {label}"
            out.append(
                f'<span class="hidden-char hidden-whitespace" '
                f'title="U+{ord(ch):04X} {char_label}">{glyph}</span>'
            )
        return "".join(out)

    parts: list[str] = [_render_outer(leading, "LEAD")]
    for ch in body:
        mapped = _VISIBLE_CHAR_MAP.get(ch)
        if mapped is not None:
            glyph, label = mapped
            cp = f"U+{ord(ch):04X}"
            css = "hidden-whitespace" if label.endswith("SP") or label in ("TAB", "LF", "CR", "NBSP") else "hidden-special"
            parts.append(
                f'<span class="hidden-char {css}" '
                f'title="{cp} {label}">{glyph}</span>'
            )
        elif _is_control(ch):
            cp = f"U+{ord(ch):04X}"
            parts.append(
                f'<span class="hidden-char hidden-control" '
                f'title="{cp} CTRL">␣</span>'
            )
        else:
            # HTML-escape only the dangerous characters; preserve everything
            # else so the visible content reads naturally.
            if ch == "&":
                parts.append("&amp;")
            elif ch == "<":
                parts.append("&lt;")
            elif ch == ">":
                parts.append("&gt;")
            else:
                parts.append(ch)
    parts.append(_render_outer(trailing, "TRAIL"))
    return "".join(parts)


def hidden_char_css() -> str:
    """CSS used by :func:`visualize_hidden_html` rendering.

    Returned as a plain string so callers can inject it into Streamlit via
    ``st.markdown(hidden_char_css(), unsafe_allow_html=True)``.
    """
    return """
<style>
.hidden-char {
    display: inline-block;
    padding: 0 2px;
    margin: 0 1px;
    border-radius: 3px;
    font-family: ui-monospace, SFMono-Regular, monospace;
    font-size: 0.85em;
    cursor: help;
}
.hidden-char.hidden-whitespace {
    background: #fff3cd;
    color: #856404;
    border: 1px solid #ffeaa7;
}
.hidden-char.hidden-special {
    background: #d1ecf1;
    color: #0c5460;
    border: 1px solid #bee5eb;
}
.hidden-char.hidden-control {
    background: #f8d7da;
    color: #721c24;
    border: 1px solid #f5c6cb;
}
</style>
"""
