"""Card artwork storage (design D4, spec invitation-card).

The one place in the codebase that touches the media filesystem. Everything else asks this
module for a URL. That is the entire point: moving to object storage later changes this file
and nothing else.

Files are content-addressed — the name is a hash of the bytes — so re-uploading a changed
file produces a different URL. Caddy can then serve `/media/*` with `immutable` and no cache
anywhere ever needs invalidating, which is what makes serving artwork off disk viable at all.

SVG sanitisation uses the standard library rather than `defusedxml`. Card designs are
authored by us, so this is defence in depth rather than a hostile-input boundary, and the
guards below (no entity declarations, no scripting, no external references) cover what a
malformed or careless export can actually carry. If artwork ever becomes self-serve upload,
this is the function to revisit first — the sanitiser is deliberately conservative but it is
not a substitute for treating the uploader as untrusted.
"""

import hashlib
import logging
import re
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path

from app.config import get_settings

logger = logging.getLogger(__name__)

#: Extension per accepted content type. The stored name never reuses the uploaded filename —
#: it is attacker-influenced in the general case and useless to us in every case.
_EXTENSIONS: dict[str, str] = {
    "image/webp": ".webp",
    "image/avif": ".avif",
    "image/png": ".png",
    "image/jpeg": ".jpg",
    "image/svg+xml": ".svg",
}

SVG_TYPE = "image/svg+xml"

#: Elements that can execute, fetch, or embed. `foreignObject` is here because it smuggles
#: arbitrary HTML — including <script> — into a document that otherwise looks like a picture.
_FORBIDDEN_ELEMENTS = frozenset(
    {"script", "foreignObject", "iframe", "embed", "object", "handler", "set", "audio", "video"}
)

#: Anything that points somewhere. Fragments (`#gradient-1`) and inline data images are the
#: two legitimate uses in an exported card; everything else phones home.
_REFERENCE_ATTRS = frozenset({"href", "{http://www.w3.org/1999/xlink}href", "src"})

_SVG_NS = "http://www.w3.org/2000/svg"
_XLINK_NS = "http://www.w3.org/1999/xlink"

#: An entity declaration in an uploaded file has no legitimate purpose and is how expansion
#: attacks start. A bare DOCTYPE is different — Illustrator emits one — so it is stripped
#: rather than rejected.
_ENTITY_DECL = re.compile(rb"<!ENTITY", re.IGNORECASE)
_DOCTYPE = re.compile(rb"<!DOCTYPE[^>[]*(\[[^\]]*\])?[^>]*>", re.IGNORECASE | re.DOTALL)

_URL_SAFE_SCHEME = re.compile(r"^(#|data:image/(png|jpeg|webp|gif);base64,)", re.IGNORECASE)

#: Link-preview images are raster only, and narrower than the card's accepted types (design
#: D6). WebP and AVIF are better formats and are still refused: unfurl support for them is
#: inconsistent across iMessage, Slack and older Android WhatsApp builds, and a preview that
#: silently fails on one platform is worse than a larger file. SVG no unfurler renders at all.
PREVIEW_TYPES = ("image/png", "image/jpeg")

#: WhatsApp drops from the large preview card to a small square thumbnail above roughly this
#: size. The cap encodes that platform behaviour, not a storage concern — the file is served
#: off the same disk as artwork many times larger.
PREVIEW_MAX_BYTES = 300_000

#: Below this, platforms fall back to a small thumbnail regardless of the file's aspect ratio.
PREVIEW_MIN_WIDTH = 600
PREVIEW_MIN_HEIGHT = 315

#: 1200x630. Warned rather than enforced: an off-ratio image crops, and refusing a usable
#: picture over a shape preference costs a designer a re-export for a cosmetic reason.
PREVIEW_TARGET_RATIO = 1200 / 630
PREVIEW_RATIO_TOLERANCE = 0.2

_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"

#: Frame headers carry the dimensions. C4 (Huffman table), C8 (JPEG extension) and CC
#: (arithmetic coding conditioning) share the numeric range but are not frame headers.
_JPEG_SOF_MARKERS = frozenset(
    {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
)
#: Markers that stand alone, carrying no length field to skip over.
_JPEG_STANDALONE = frozenset({0x01, 0xD8, 0xD9}) | {0xD0 + n for n in range(8)}


class MediaRejectedError(Exception):
    """Upload refused. The message is written to be shown to the admin verbatim."""


@dataclass(frozen=True)
class StoredAsset:
    url: str
    byte_size: int
    content_type: str


@dataclass(frozen=True)
class StoredPreview:
    """A stored link-preview image. Dimensions travel with it because the meta tags declare
    them and a chat application uses them to lay out the card before the image arrives."""

    url: str
    width: int
    height: int
    byte_size: int
    content_type: str
    #: Shown to the admin on upload, never a refusal. `None` when the image is well-shaped.
    warning: str | None = None


def _media_root() -> Path:
    return Path(get_settings().media_root)


def validate(data: bytes, content_type: str) -> str:
    """Check type and size before anything is written. Returns the normalised type.

    Size is checked here rather than trusting a Content-Length header, which is a claim the
    uploader makes about bytes we have already received.
    """
    settings = get_settings()
    normalised = content_type.split(";")[0].strip().lower()

    if normalised not in settings.media_allowed_types:
        raise MediaRejectedError(
            f"{normalised or 'unknown'} is not an accepted image type. "
            f"Accepted: {', '.join(settings.media_allowed_types)}."
        )

    if len(data) > settings.media_max_bytes:
        raise MediaRejectedError(
            f"File is {len(data) / 1_048_576:.1f} MB, over the "
            f"{settings.media_max_bytes / 1_048_576:.1f} MB limit. The whole invitation page "
            "has an 800 KB budget, so oversized artwork breaks the page it decorates."
        )

    if not data:
        raise MediaRejectedError("File is empty.")

    return normalised


def _png_dimensions(data: bytes) -> tuple[int, int] | None:
    """Read width and height from a PNG's IHDR, which the format requires to come first."""
    if len(data) < 24 or not data.startswith(_PNG_SIGNATURE):
        return None
    if data[12:16] != b"IHDR":
        return None
    return (
        int.from_bytes(data[16:20], "big"),
        int.from_bytes(data[20:24], "big"),
    )


def _jpeg_dimensions(data: bytes) -> tuple[int, int] | None:
    """Walk JPEG segments to the first frame header and read its dimensions.

    A JPEG is a chain of length-prefixed segments, so reaching the frame header means
    stepping over everything before it — EXIF and colour profiles routinely come first, and
    a thumbnail embedded in EXIF is why the *first* SOF is not simply searched for by bytes.
    """
    if len(data) < 4 or not data.startswith(b"\xff\xd8"):
        return None

    position = 2
    while position + 3 < len(data):
        if data[position] != 0xFF:
            # Fill bytes are legal between segments; anything else means the chain is broken.
            position += 1
            continue

        marker = data[position + 1]
        if marker == 0xFF:
            position += 1
            continue
        if marker in _JPEG_STANDALONE:
            position += 2
            continue

        length = int.from_bytes(data[position + 2 : position + 4], "big")
        if length < 2:
            return None

        if marker in _JPEG_SOF_MARKERS:
            if position + 9 > len(data):
                return None
            return (
                int.from_bytes(data[position + 7 : position + 9], "big"),
                int.from_bytes(data[position + 5 : position + 7], "big"),
            )

        # 0xDA starts the entropy-coded scan, where marker parsing no longer applies. A file
        # that reaches it without a frame header is one we cannot measure.
        if marker == 0xDA:
            return None

        position += 2 + length

    return None


def read_dimensions(data: bytes, content_type: str) -> tuple[int, int] | None:
    """Pixel size of a PNG or JPEG, or None if the bytes do not parse as one.

    Parsed from the file header with the standard library rather than with an imaging
    library: two formats at fixed offsets, and it keeps an imaging dependency out of an API
    image that needs one for nothing else (design D6).
    """
    if content_type == "image/png":
        return _png_dimensions(data)
    if content_type == "image/jpeg":
        return _jpeg_dimensions(data)
    return None


def validate_preview_image(data: bytes, content_type: str) -> tuple[str, int, int, str | None]:
    """Check a link-preview image. Returns its type, width, height and any warning.

    Every refusal names the value that failed and the limit it failed against, so a designer
    can fix the export without a round trip (spec link-preview).
    """
    normalised = content_type.split(";")[0].strip().lower()

    if not data:
        raise MediaRejectedError("The preview image is empty.")

    if normalised not in PREVIEW_TYPES:
        raise MediaRejectedError(
            f"{normalised or 'unknown'} cannot be used as a link preview image. "
            "Accepted: PNG or JPEG. Chat applications do not render SVG previews, and "
            "support for WebP and AVIF is inconsistent across them."
        )

    if len(data) > PREVIEW_MAX_BYTES:
        raise MediaRejectedError(
            f"The preview image is {len(data) / 1000:.0f} KB, over the "
            f"{PREVIEW_MAX_BYTES / 1000:.0f} KB limit. Above that WhatsApp shows a small "
            "square thumbnail instead of the large preview card."
        )

    dimensions = read_dimensions(data, normalised)
    if dimensions is None:
        raise MediaRejectedError(
            "The preview image could not be read as a PNG or JPEG. Re-export it from the "
            "design tool."
        )

    width, height = dimensions
    if width < PREVIEW_MIN_WIDTH or height < PREVIEW_MIN_HEIGHT:
        raise MediaRejectedError(
            f"The preview image is {width}x{height}, below the {PREVIEW_MIN_WIDTH}x"
            f"{PREVIEW_MIN_HEIGHT} minimum. Smaller images are shown as a thumbnail rather "
            "than a preview card. Export it at 1200x630."
        )

    warning: str | None = None
    ratio = width / height
    if abs(ratio - PREVIEW_TARGET_RATIO) > PREVIEW_RATIO_TOLERANCE:
        warning = (
            f"The preview image is {width}x{height}. Chat applications crop to roughly "
            "1200x630, so parts of it will be cut off — check that the names stay in frame."
        )

    return normalised, width, height, warning


def save_preview(data: bytes, content_type: str, event_id: uuid.UUID) -> StoredPreview:
    """Validate and store a link-preview image.

    Separate entry point from `save` because the rules differ in every respect — narrower
    types, a much smaller cap, a dimension floor — but it writes through the same
    content-addressed path, so a preview image is served and cached like any other asset.
    """
    normalised, width, height, warning = validate_preview_image(data, content_type)
    url = _write(data, normalised, event_id)
    return StoredPreview(
        url=url,
        width=width,
        height=height,
        byte_size=len(data),
        content_type=normalised,
        warning=warning,
    )


def _is_safe_reference(value: str) -> bool:
    return bool(_URL_SAFE_SCHEME.match(value.strip()))


def sanitize_svg(data: bytes) -> bytes:
    """Strip scripting, event handlers and external references from an SVG.

    Visual content is preserved: this removes capability, not appearance.
    """
    if _ENTITY_DECL.search(data):
        raise MediaRejectedError(
            "SVG contains an entity declaration, which no card export needs. "
            "Re-export it from the design tool without a DTD."
        )

    body = _DOCTYPE.sub(b"", data)

    ET.register_namespace("", _SVG_NS)
    ET.register_namespace("xlink", _XLINK_NS)
    try:
        root = ET.fromstring(body)
    except ET.ParseError as exc:
        raise MediaRejectedError(f"SVG could not be parsed: {exc}") from exc

    # Parent map: ElementTree gives no upward link, and an element cannot remove itself.
    parents = {child: parent for parent in root.iter() for child in parent}

    removed: list[str] = []
    for element in list(root.iter()):
        local = element.tag.rpartition("}")[2] if isinstance(element.tag, str) else ""

        if local in _FORBIDDEN_ELEMENTS:
            parent = parents.get(element)
            if parent is not None:
                parent.remove(element)
                removed.append(local)
            continue

        for name, value in list(element.attrib.items()):
            bare = name.rpartition("}")[2].lower()
            # Event handlers and outbound references are the same problem — something in the
            # picture reaching outside it — so they are removed on the same terms.
            if bare.startswith("on") or (
                name in _REFERENCE_ATTRS and not _is_safe_reference(value)
            ):
                del element.attrib[name]
                removed.append(f"@{bare}")
            elif bare == "style" and ("javascript:" in value.lower() or "url(" in value.lower()):
                del element.attrib[name]
                removed.append("@style")

    if removed:
        logger.info("sanitised SVG upload, removed: %s", ", ".join(sorted(set(removed))))

    # Annotated because the overload for `encoding="utf-8"` still widens to Any in the
    # stubs; passing an encoding is precisely what makes this bytes rather than str.
    serialised: bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
    return serialised


def _write(data: bytes, normalised: str, event_id: uuid.UUID) -> str:
    """Content-address and write, returning the public URL.

    Writing the same bytes twice is a no-op that returns the same URL, which makes an
    interrupted upload safe to retry.
    """
    digest = hashlib.sha256(data).hexdigest()[:16]
    name = f"{digest}{_EXTENSIONS[normalised]}"
    relative = Path("cards") / str(event_id) / name

    destination = _media_root() / relative
    destination.parent.mkdir(parents=True, exist_ok=True)
    if not destination.exists():
        # Write beside then rename: a reader can never observe a half-written file, and a
        # crash mid-write leaves a stray temp rather than a corrupt asset at a live URL.
        temp = destination.with_suffix(destination.suffix + ".part")
        temp.write_bytes(data)
        temp.replace(destination)

    prefix = get_settings().media_url_prefix.rstrip("/")
    return f"{prefix}/{relative.as_posix()}"


def save(data: bytes, content_type: str, event_id: uuid.UUID) -> StoredAsset:
    """Validate, sanitise if needed, and write. Returns the public URL."""
    normalised = validate(data, content_type)

    if normalised == SVG_TYPE:
        data = sanitize_svg(data)

    return StoredAsset(
        url=_write(data, normalised, event_id),
        byte_size=len(data),
        content_type=normalised,
    )


def resolve(url: str) -> Path | None:
    """Map a stored URL back to its file, or None if it is not one of ours.

    The containment check is what stops `/media/../../etc/passwd` from resolving to
    something real — a URL reaching here has been through the database, but a path built
    from stored text still gets verified.
    """
    prefix = get_settings().media_url_prefix.rstrip("/") + "/"
    if not url.startswith(prefix):
        return None

    root = _media_root().resolve()
    candidate = (root / url[len(prefix) :]).resolve()
    if root not in candidate.parents:
        logger.warning("refused media path outside the media root")
        return None
    return candidate


def delete(url: str) -> None:
    """Remove a stored asset. Missing is success — deletion is meant to be idempotent."""
    path = resolve(url)
    if path is not None:
        path.unlink(missing_ok=True)


def total_bytes(urls: list[str]) -> int:
    """Combined on-disk size of a design's assets, for the admin's budget warning.

    A missing file counts as zero rather than raising: this drives a warning label, and it
    must not be the thing that breaks the page showing it.
    """
    total = 0
    for url in urls:
        path = resolve(url)
        if path is not None and path.is_file():
            total += path.stat().st_size
    return total
