"""html_sanitize.py — stdlib-only allowlist HTML sanitizer.

No compiled dependencies (no nh3/bleach).  Uses html.parser.HTMLParser to
rebuild safe HTML from an untrusted input.  Designed as the *sanitize* layer
for the defense-in-depth approach; the primary barrier is the sandbox iframe
(see unified_staging_review.py / staging_server.py).

Public API:
    sanitize_html(html_str: str) -> str
"""

from __future__ import annotations

import html
from html.parser import HTMLParser
from urllib.parse import unquote as url_unquote


# ---------------------------------------------------------------------------
# Allowlists
# ---------------------------------------------------------------------------

# Tags whose entire subtree (including text) is dropped.
# These tags are either executable (script/style) or structural containers
# that cannot be rendered safely.  Note: <form> is NOT here — its text
# content is preserved; only the <form> element tag itself is dropped
# (it is not in _ALLOW_TAGS either, so the tag node is silently omitted
# but its children are emitted).
_DROP_TAGS: frozenset[str] = frozenset(
    {
        "script",
        "style",
        "iframe",
        "object",
        "embed",
        "link",
        "meta",
        "base",
        "noscript",
        "template",
        "svg",      # vectors can carry JS payloads
        "math",     # same
    }
)

# Tags kept in output (inner text always preserved even for unknown tags,
# but unknown tags are dropped as structural elements).
_ALLOW_TAGS: frozenset[str] = frozenset(
    {
        "p", "div", "span", "a", "img",
        "ul", "ol", "li",
        "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption",
        "h1", "h2", "h3", "h4", "h5", "h6",
        "strong", "em", "b", "i", "u", "s", "del", "ins",
        "br", "hr",
        "blockquote", "pre", "code", "kbd", "samp",
        "sub", "sup",
        "figure", "figcaption",
        "section", "article", "header", "footer", "aside",
        "small", "mark", "abbr", "cite", "q", "dfn", "var",
        "dl", "dt", "dd",
        "details", "summary",
    }
)

# Attributes that are globally safe regardless of tag.
_ALLOW_ATTRS_GLOBAL: frozenset[str] = frozenset(
    {
        "title",
        "style",
        "class",
        "id",
        "lang",
        "dir",
        "tabindex",
        "role",         # ARIA landmark role (globally useful)
        "hidden",       # HTML boolean attribute
    }
)

# Attributes allowed only on specific tags.
_ALLOW_ATTRS_BY_TAG: dict[str, frozenset[str]] = {
    "a": frozenset({"href", "target", "rel", "download"}),
    "img": frozenset({"src", "alt", "width", "height", "loading"}),
    "td": frozenset({"colspan", "rowspan", "headers"}),
    "th": frozenset({"colspan", "rowspan", "scope", "headers"}),
    "table": frozenset({"width", "border", "cellpadding", "cellspacing"}),
    "col": frozenset({"span", "width"}),
    "colgroup": frozenset({"span"}),
    "details": frozenset({"open"}),
    "ol": frozenset({"start", "type", "reversed"}),
    "li": frozenset({"value"}),
}

# URL attributes whose values must be validated.
_URL_ATTRS: frozenset[str] = frozenset({"href", "src", "action", "formaction", "srcdoc", "xlink:href"})

# Prefixes that are never safe in URL attributes (after normalise).
_DANGEROUS_URL_PREFIXES: tuple[str, ...] = (
    "javascript:",
    "vbscript:",
    "data:text/html",
    "data:application",
    "data:text/xml",
)

# Attribute prefixes that are always dropped (event handlers + data-binding).
_DROP_ATTR_PREFIXES: tuple[str, ...] = ("on",)

# Attribute prefixes that are always allowed globally.
_ALLOW_ATTR_PREFIXES: tuple[str, ...] = ("aria-", "data-")


def _is_safe_url(value: str) -> bool:
    """Return True if a URL attribute value is safe."""
    if value is None:
        return True
    # Decode HTML entities, then percent-encode sequences.
    decoded = html.unescape(value)
    decoded = url_unquote(decoded)
    # Strip leading whitespace and control characters (including \\x00-\\x1f).
    normalised = "".join(ch for ch in decoded if ord(ch) > 0x1F or ch in "\t").strip().lower()
    # Remove CSS expression() / comments that can smuggle javascript:
    # e.g. "java\tscript:" or "java\nscript:"
    normalised = normalised.replace("\t", "").replace("\n", "").replace("\r", "").replace(" ", "")
    for prefix in _DANGEROUS_URL_PREFIXES:
        if normalised.startswith(prefix):
            return False
    return True


def _is_allowed_attr(tag: str, attr: str, value: str | None) -> bool:
    """Return True if (tag, attr, value) should be kept."""
    attr_lower = attr.lower()

    # Drop event handlers (on*).
    for prefix in _DROP_ATTR_PREFIXES:
        if attr_lower.startswith(prefix):
            return False

    # Allow aria-* and data-* globally.
    for prefix in _ALLOW_ATTR_PREFIXES:
        if attr_lower.startswith(prefix):
            return True

    # Globally-safe attributes.
    if attr_lower in _ALLOW_ATTRS_GLOBAL:
        return True

    # Tag-specific attributes.
    tag_specific = _ALLOW_ATTRS_BY_TAG.get(tag, frozenset())
    if attr_lower in tag_specific:
        # URL attributes get an extra safety check.
        if attr_lower in _URL_ATTRS:
            return _is_safe_url(value or "")
        return True

    return False


class _SanitizerParser(HTMLParser):
    """Stateful HTMLParser that reconstructs safe HTML."""

    def __init__(self) -> None:
        super().__init__(convert_charrefs=False)
        self._output: list[str] = []
        # Stack of drop-depth: we nest this so that e.g.
        # <script><div>hi</div></script> doesn't leak "hi".
        self._drop_depth: int = 0
        # Void elements that never need a closing tag.
        self._void_elements: frozenset[str] = frozenset(
            {
                "area", "base", "br", "col", "embed", "hr",
                "img", "input", "link", "meta", "param",
                "source", "track", "wbr",
            }
        )

    # --- Internal helpers ---

    def _emit(self, text: str) -> None:
        if self._drop_depth == 0:
            self._output.append(text)

    # --- HTMLParser overrides ---

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        if tag in _DROP_TAGS:
            # Enter a drop zone — track nesting depth.
            self._drop_depth += 1
            return

        if self._drop_depth > 0:
            # Inside a drop zone; still track structural nesting for _DROP_TAGS
            # so we pop correctly on endtag.
            return

        if tag not in _ALLOW_TAGS:
            # Unknown tag: drop the tag itself but keep inner text
            # (handled by not emitting anything; endtag will also be dropped).
            return

        # Build attribute string.
        attr_parts: list[str] = []
        for attr, value in attrs:
            if _is_allowed_attr(tag, attr, value):
                if value is None:
                    attr_parts.append(html.escape(attr, quote=True))
                else:
                    attr_parts.append(
                        f'{html.escape(attr, quote=True)}="{html.escape(value, quote=True)}"'
                    )

        tag_str = f"<{html.escape(tag, quote=True)}"
        if attr_parts:
            tag_str += " " + " ".join(attr_parts)

        if tag in self._void_elements:
            tag_str += ">"
        else:
            tag_str += ">"

        self._emit(tag_str)

    def handle_endtag(self, tag: str) -> None:
        if tag in _DROP_TAGS:
            if self._drop_depth > 0:
                self._drop_depth -= 1
            return

        if self._drop_depth > 0:
            return

        if tag not in _ALLOW_TAGS:
            # Unknown tag: drop.
            return

        if tag not in self._void_elements:
            self._emit(f"</{html.escape(tag, quote=True)}>")

    def handle_data(self, data: str) -> None:
        if self._drop_depth > 0:
            return
        self._emit(html.escape(data, quote=False))

    def handle_entityref(self, name: str) -> None:
        # Named entity references (e.g. &amp;).
        if self._drop_depth > 0:
            return
        self._emit(f"&{html.escape(name, quote=False)};")

    def handle_charref(self, name: str) -> None:
        # Numeric character references (e.g. &#106; or &#x6a;).
        if self._drop_depth > 0:
            return
        self._emit(f"&#{name};")

    def handle_comment(self, data: str) -> None:
        # Drop all HTML comments (can contain IE conditional code).
        pass

    def handle_decl(self, decl: str) -> None:
        # Drop DOCTYPE and similar.
        pass

    def unknown_decl(self, data: str) -> None:
        pass

    def handle_pi(self, data: str) -> None:
        pass

    def get_output(self) -> str:
        return "".join(self._output)


def sanitize_html(html_str: str) -> str:
    """Return a sanitized copy of *html_str*.

    - Drops dangerous tags and their subtrees entirely (script, style, iframe …).
    - Strips event-handler attributes (on*) and unsafe URL values
      (javascript:, data:text/html, vbscript:).
    - Keeps a permissive allowlist of tags/attrs sufficient for Canvas content.
    - Unknown tags are dropped structurally but their inner text is preserved.
    - Resilient to malformed/truncated HTML (HTMLParser tolerates it).
    """
    if not html_str:
        return html_str or ""
    parser = _SanitizerParser()
    try:
        parser.feed(html_str)
        parser.close()
    except Exception:
        # Parsing error → return empty string rather than potentially unsafe content.
        return ""
    return parser.get_output()
