"""Sanitising and rewriting an uploaded card document (tasks 5.1, 5.2, design D4, D5).

A card is a self-contained HTML file our design team authors. Two things have to happen to
it between upload and rendering, and both are done once here rather than at every read:

**Capability is removed.** Scripting, event handlers and anything pointing at another host
come out. The design team is trusted, but the file arrives through a browser upload and is
injected into a page every guest of the wedding opens — trusted authorship is a reason this
is defence in depth rather than a hostile boundary, not a reason to skip it. An external
reference is *rejected* rather than stripped: silently dropping the background image would
publish a card the designer never saw, and they would find out from the customer.

**Relative references are rewritten.** The designer writes `background.svg`; the document is
injected into a page served from `/i/{token}`, where that resolves to `/i/background.svg` and
404s (design D5). Every relative reference is therefore rewritten to the content-addressed
URL of its companion file, and a reference with no matching upload blocks publication.

The parser is `html.parser` from the standard library. It is lenient in the same direction a
browser is, which matters: a sanitiser that is stricter than the renderer would pass a
document the browser then interprets differently.
"""

import re
from html import escape
from html.parser import HTMLParser
from urllib.parse import urlparse

#: Removed with their contents. `script` executes; `iframe`, `object` and `embed` load
#: another document; `base` silently re-points every relative URL in the page it lands in,
#: which would defeat the rewriting below.
_FORBIDDEN_ELEMENTS = frozenset({"script", "iframe", "object", "embed", "base", "meta", "link"})

#: Void elements, which must be re-emitted without a closing tag or the tree shifts.
_VOID_ELEMENTS = frozenset(
    {
        "area", "base", "br", "col", "embed", "hr", "img", "input",
        "link", "meta", "param", "source", "track", "wbr",
    }
)  # fmt: skip

#: Attributes that carry a URL. Anything else is left alone.
_URL_ATTRS = frozenset({"src", "href", "poster", "data", "srcset"})

_CSS_URL = re.compile(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)", re.IGNORECASE)
_DATA_URI = re.compile(r"^data:image/(png|jpe?g|webp|gif|svg\+xml);base64,", re.IGNORECASE)


class CardRejectedError(Exception):
    """Upload refused. The message names the offending reference and is shown verbatim."""


def _is_external(url: str) -> bool:
    """A reference that leaves our origin.

    Protocol-relative `//fonts.example.com/x.css` is caught by the netloc check, which is
    the case a scheme-only test misses.
    """
    parsed = urlparse(url.strip())
    return bool(parsed.scheme in {"http", "https"} or parsed.netloc)


def _is_inline(url: str) -> bool:
    """Data URIs and same-document fragments need no rewriting and reach nothing outside."""
    value = url.strip()
    return value.startswith("#") or bool(_DATA_URI.match(value))


def _resolve(url: str, assets: dict[str, str], missing: set[str]) -> str:
    """Map one authored reference to its stored URL, recording it if there is no match."""
    value = url.strip()
    if not value or _is_inline(value):
        return value
    if _is_external(value):
        raise CardRejectedError(
            f"The card references {value}, which is on another server. Every asset must be "
            "uploaded with the card so the invitation keeps working if that server does not."
        )
    if value.startswith("/media/"):
        return value  # already rewritten, e.g. a re-upload of a stored document

    # Authored as a plain filename beside the document. Leading ./ and any folder the
    # designer's export added are ignored — only the filename identifies the companion.
    name = value.split("?")[0].split("#")[0].lstrip("./").rsplit("/", 1)[-1]
    stored = assets.get(name)
    if stored is None:
        missing.add(name)
        return value
    return stored


def rewrite_css(css: str, assets: dict[str, str], missing: set[str]) -> str:
    """Rewrite `url(...)` references inside a stylesheet or a style attribute."""

    def replace(match: re.Match[str]) -> str:
        return f"url({_resolve(match.group(1), assets, missing)})"

    return _CSS_URL.sub(replace, css)


class _Rewriter(HTMLParser):
    """Re-emits the document with capability removed and references rewritten.

    `convert_charrefs=False` so entities pass through exactly as authored — converting them
    and re-escaping is a round trip that mangles `&amp;amp;` and anything inside a
    stylesheet.
    """

    def __init__(self, assets: dict[str, str]) -> None:
        super().__init__(convert_charrefs=False)
        self.assets = assets
        self.missing: set[str] = set()
        self.removed: set[str] = set()
        self.out: list[str] = []
        #: Depth inside a forbidden element, so its children are dropped too.
        self._skip_depth = 0
        self._in_style = False

    # -- helpers

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

    def _attrs(self, tag: str, attrs: list[tuple[str, str | None]]) -> str:
        parts: list[str] = []
        for name, value in attrs:
            lowered = name.lower()
            if lowered.startswith("on"):
                self.removed.add(f"@{lowered}")
                continue
            if value is None:
                parts.append(f" {escape(lowered, quote=True)}")
                continue
            if lowered == "style":
                value = rewrite_css(value, self.assets, self.missing)
            elif lowered == "srcset":
                # `a.png 1x, b.png 2x` — each candidate is a URL plus a descriptor.
                value = ", ".join(
                    " ".join(
                        [_resolve(part.split()[0], self.assets, self.missing), *part.split()[1:]]
                    )
                    for part in value.split(",")
                    if part.strip()
                )
            elif lowered in _URL_ATTRS:
                value = _resolve(value, self.assets, self.missing)
            parts.append(f' {escape(lowered, quote=True)}="{escape(value, quote=True)}"')
        return "".join(parts)

    # -- parser callbacks

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        if self._skip_depth:
            if tag not in _VOID_ELEMENTS:
                self._skip_depth += 1
            return
        if tag in _FORBIDDEN_ELEMENTS:
            self.removed.add(tag)
            if tag not in _VOID_ELEMENTS:
                self._skip_depth = 1
            return
        self._in_style = tag == "style"
        self._emit(f"<{tag}{self._attrs(tag, attrs)}>")

    def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        if tag in _FORBIDDEN_ELEMENTS:
            self.removed.add(tag)
            return
        self._emit(f"<{tag}{self._attrs(tag, attrs)}/>")

    def handle_endtag(self, tag: str) -> None:
        if self._skip_depth:
            self._skip_depth -= 1
            return
        if tag in _FORBIDDEN_ELEMENTS:
            return
        if tag == "style":
            self._in_style = False
        self._emit(f"</{tag}>")

    def handle_data(self, data: str) -> None:
        # Inside <style> the text is CSS and carries url() references; everywhere else it is
        # text and is emitted exactly as authored.
        self._emit(rewrite_css(data, self.assets, self.missing) if self._in_style else data)

    def handle_entityref(self, name: str) -> None:
        self._emit(f"&{name};")

    def handle_charref(self, name: str) -> None:
        self._emit(f"&#{name};")

    def handle_comment(self, data: str) -> None:
        # Dropped. Conditional comments are executable in old engines, and a designer's
        # working notes are not something to publish to every guest.
        return

    def handle_decl(self, decl: str) -> None:
        # The document is injected into a shadow root, where a doctype is meaningless.
        return


class SanitisedCard:
    """The cleaned document plus what had to be removed, so the admin can be told."""

    def __init__(self, html: str, removed: set[str], missing: set[str]) -> None:
        self.html = html
        self.removed = sorted(removed)
        self.missing = sorted(missing)


def sanitise(document: str, assets: dict[str, str]) -> SanitisedCard:
    """Clean an uploaded card document and rewrite its references.

    `assets` maps the filename the designer wrote to the URL it was stored at. Missing
    companions are collected rather than raised on, so the admin sees every one at once
    instead of fixing them one upload at a time.
    """
    rewriter = _Rewriter(assets)
    rewriter.feed(document)
    rewriter.close()
    return SanitisedCard("".join(rewriter.out), rewriter.removed, rewriter.missing)
