"""Plain-text sanitisation helpers.

INDL-44 (Memorial Tribute Moderation) — tribute messages and derived display
names are user-controlled content that is later rendered on the unauthenticated
public memorial page (`/memorial/:slug`). To eliminate the stored-XSS surface
(OWASP A03 / API3) these helpers strip **all** HTML markup with an empty
allowlist — tributes are plain text, never rich text — and remove control
characters that enable log-injection or BIDI-override attacks.

The project intentionally has no `bleach`/`nh3` dependency; these use only the
standard library so the security control has no supply-chain footprint.
"""
from __future__ import annotations

import re
from html.parser import HTMLParser

# Null byte + C0/C1 control characters, excluding the common whitespace we keep
# (\t \n \r). These break Postgres text storage and enable log/BIDI injection.
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
# Collapse runs of horizontal whitespace but preserve newlines for readability.
_WS_RUN_RE = re.compile(r"[ \t\f\v]+")


class NullByteError(ValueError):
    """Raised when input contains a NUL byte — rejected outright (never stripped)."""


class _TagStripper(HTMLParser):
    """Extracts text content, dropping every tag and the body of <script>/<style>.

    convert_charrefs=True means entities inside text (e.g. ``&amp;``) are decoded
    to their literal characters, so the output is true plain text with no residual
    markup that a downstream renderer could interpret.
    """

    _DROP_CONTENT = frozenset({"script", "style"})

    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self._chunks: list[str] = []
        self._suppress_depth = 0

    def handle_starttag(self, tag: str, attrs) -> None:  # noqa: ANN001
        if tag in self._DROP_CONTENT:
            self._suppress_depth += 1

    def handle_endtag(self, tag: str) -> None:
        if tag in self._DROP_CONTENT and self._suppress_depth > 0:
            self._suppress_depth -= 1

    def handle_data(self, data: str) -> None:
        if self._suppress_depth == 0:
            self._chunks.append(data)

    def get_text(self) -> str:
        return "".join(self._chunks)


def strip_html(text: str) -> str:
    """Return *text* with all HTML tags removed and entities decoded to literals."""
    parser = _TagStripper()
    parser.feed(text)
    parser.close()
    return parser.get_text()


def sanitize_plain_text(text: str, *, max_length: int | None = None) -> str:
    """Sanitise free-form user text into safe plain text.

    - Rejects NUL bytes (raises :class:`NullByteError`).
    - Strips all HTML tags/markup (empty allowlist) and drops <script>/<style> bodies.
    - Removes remaining control characters.
    - Collapses horizontal whitespace runs and trims the result.
    - Optionally enforces a maximum length (raises ``ValueError`` on violation),
      measured against the *sanitised* text.
    """
    if "\x00" in text:
        raise NullByteError("Input contains a null byte")

    cleaned = strip_html(text)
    cleaned = _CONTROL_CHARS_RE.sub("", cleaned)
    cleaned = _WS_RUN_RE.sub(" ", cleaned).strip()

    if max_length is not None and len(cleaned) > max_length:
        raise ValueError(f"Text exceeds maximum length of {max_length} characters")

    return cleaned


def sanitize_display_name(name: str | None, *, max_length: int = 200) -> str | None:
    """Sanitise a derived display / deceased name for inclusion in API responses.

    Strips HTML and control characters, collapses whitespace, and truncates to
    *max_length* (never raises — a name that is too long is truncated, not
    rejected, since it is server-derived, not user-submitted at this point).
    Returns ``None`` for empty / None input.
    """
    if not name:
        return None
    cleaned = strip_html(name)
    cleaned = _CONTROL_CHARS_RE.sub("", cleaned)
    cleaned = _WS_RUN_RE.sub(" ", cleaned).strip()
    if not cleaned:
        return None
    return cleaned[:max_length]
