"""Storing uploaded images.

Only model avatars use this today. Files are written under the data directory
and served back by a dedicated route, never from a URL supplied by a user --
a remote image URL would turn every page render into a request to a third
party, which is both a privacy leak and a way to make the UI depend on someone
else's uptime.
"""

from __future__ import annotations

import logging
import secrets
from pathlib import Path

from lembas.config import settings

log = logging.getLogger(__name__)

# Raster and vector formats a browser will render inline. Deliberately narrow:
# every entry here is something that cannot execute in an <img> tag.
ALLOWED_TYPES: dict[str, str] = {
    "image/png": ".png",
    "image/jpeg": ".jpg",
    "image/webp": ".webp",
    "image/gif": ".gif",
}

MAX_BYTES = 2 * 1024 * 1024  # 2 MB; these are 64px avatars

# Magic numbers, checked against the declared content type. A browser sniffs
# content, so trusting the client's Content-Type alone would let a file claim
# to be a PNG and be served as something else.
_SIGNATURES: tuple[tuple[bytes, str], ...] = (
    (b"\x89PNG\r\n\x1a\n", "image/png"),
    (b"\xff\xd8\xff", "image/jpeg"),
    (b"GIF87a", "image/gif"),
    (b"GIF89a", "image/gif"),
)


class UploadError(Exception):
    """A rejected upload, with a message fit to show the user."""


def _detect(payload: bytes) -> str | None:
    for signature, media_type in _SIGNATURES:
        if payload.startswith(signature):
            return media_type
    # WEBP is "RIFF" + 4 size bytes + "WEBP".
    if payload[:4] == b"RIFF" and payload[8:12] == b"WEBP":
        return "image/webp"
    return None


def models_dir() -> Path:
    path = settings.uploads_dir / "models"
    path.mkdir(parents=True, exist_ok=True)
    return path


def save_model_image(payload: bytes, declared_type: str) -> str:
    """Validate and store a model avatar. Returns the stored filename."""
    if not payload:
        raise UploadError("The file was empty.")
    if len(payload) > MAX_BYTES:
        raise UploadError(f"Images must be under {MAX_BYTES // (1024 * 1024)} MB.")

    actual = _detect(payload)
    if actual is None:
        raise UploadError("That does not look like a PNG, JPEG, WEBP or GIF image.")
    if declared_type and declared_type.split(";")[0].strip() != actual:
        # Not fatal on its own, but worth knowing about.
        log.info("upload declared %s but is actually %s", declared_type, actual)

    # Random name rather than the client's: no path traversal, no collisions,
    # and no leaking whatever the uploader called the file.
    filename = f"{secrets.token_hex(16)}{ALLOWED_TYPES[actual]}"
    (models_dir() / filename).write_bytes(payload)
    return filename


def model_image_path(filename: str) -> Path | None:
    """Resolve a stored filename to a path, refusing anything outside the dir."""
    if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
        return None
    path = (models_dir() / filename).resolve()
    try:
        path.relative_to(models_dir().resolve())
    except ValueError:
        return None
    return path if path.is_file() else None


def delete_model_image(filename: str) -> None:
    path = model_image_path(filename)
    if path is not None:
        path.unlink(missing_ok=True)


# --- Branding assets -----------------------------------------------------------
# The logo, the favicon and the launcher icons derived from them. A separate
# directory from the model avatars because the *route* differs: these are served
# unauthenticated, since the sign-in page and the web app manifest both need
# them and neither has a session to check.
#
# SVG stays excluded, and this is where somebody will most want it. Every entry
# in ALLOWED_TYPES is a format that cannot execute in an `<img>`; an SVG can, and
# these are the one set of files served to somebody who is not signed in.

# What a launcher asks for, and what `<link rel="apple-touch-icon">` wants.
# Generated from the uploaded logo rather than asked for separately: an
# administrator who has a logo has said everything they need to say, and five
# upload fields to fill in by hand is how three of them end up wrong.
ICON_SIZES: dict[str, int] = {
    "icon-192": 192,
    "icon-512": 512,
    "apple-touch": 180,
    "favicon": 32,
}

# The maskable icon has to survive being cropped to a circle, so the artwork
# sits inside the safe zone with the background showing around it. 80% is the
# standard's own guidance and is what the shipped icon already uses.
MASKABLE_SIZE = 512
MASKABLE_INSET = 0.8


def branding_dir() -> Path:
    path = settings.uploads_dir / "branding"
    path.mkdir(parents=True, exist_ok=True)
    return path


def save_branding_image(payload: bytes, declared_type: str) -> str:
    """Validate and store a logo or favicon. Returns the stored filename."""
    if not payload:
        raise UploadError("The file was empty.")
    if len(payload) > MAX_BYTES:
        raise UploadError(f"Images must be under {MAX_BYTES // (1024 * 1024)} MB.")
    actual = _detect(payload)
    if actual is None:
        raise UploadError(
            "That does not look like a PNG, JPEG, WEBP or GIF image. "
            "SVG is deliberately not accepted: these files are served to people "
            "who are not signed in, and an SVG can carry a script."
        )
    if declared_type and declared_type.split(";")[0].strip() != actual:
        log.info("upload declared %s but is actually %s", declared_type, actual)

    filename = f"{secrets.token_hex(16)}{ALLOWED_TYPES[actual]}"
    (branding_dir() / filename).write_bytes(payload)
    return filename


def derive_icons(payload: bytes) -> dict[str, str]:
    """Launcher icons from an uploaded logo, at the sizes a browser asks for.

    Best-effort: an instance whose logo cannot be resized keeps the shipped
    icons, which is a worse launcher tile and not a broken install. Pillow is
    already a dependency (`services/files.py` uses it for attachments), so this
    adds nothing to install.

    Every output is PNG regardless of what came in, because that is what a
    manifest icon has to be, and RGBA so a logo with a transparent background
    stays one.
    """
    try:
        import io

        from PIL import Image
    except Exception:  # noqa: BLE001 - Pillow missing is not a failed save
        log.info("Pillow unavailable; keeping the shipped launcher icons")
        return {}

    try:
        with Image.open(io.BytesIO(payload)) as source:
            source.load()
            image = source.convert("RGBA")
    except Exception:  # noqa: BLE001 - a file we stored but cannot read
        log.info("could not read the uploaded logo for icons", exc_info=True)
        return {}

    paths: dict[str, str] = {}
    for name, size in ICON_SIZES.items():
        paths[name] = _write_png(_fitted(image, size, size), f"{name}")
    # Cropped to a circle on Android, so the artwork is inset and the corners
    # are filled rather than transparent -- a transparent maskable icon is
    # rendered as a black square by some launchers.
    canvas = _new_canvas(MASKABLE_SIZE, image)
    inner = _fitted(image, int(MASKABLE_SIZE * MASKABLE_INSET), int(MASKABLE_SIZE * MASKABLE_INSET))
    offset = (MASKABLE_SIZE - inner.width) // 2, (MASKABLE_SIZE - inner.height) // 2
    canvas.alpha_composite(inner, offset)
    paths["maskable"] = _write_png(canvas, "maskable")
    return paths


def _fitted(image, width: int, height: int):
    from PIL import Image

    return image.copy().resize((width, height), Image.LANCZOS)


def _new_canvas(size: int, source):
    """A square the colour of the logo's top-left pixel, or transparent.

    Sampling one pixel rather than averaging: a logo on a flat background gets
    that background, which is what the inset needs, and a logo on a transparent
    one gets transparency, which the composite below then fills.
    """
    from PIL import Image

    corner = source.getpixel((0, 0))
    fill = corner if isinstance(corner, tuple) and len(corner) == 4 else (0, 0, 0, 0)
    return Image.new("RGBA", (size, size), fill)


def _write_png(image, label: str) -> str:
    import io

    buffer = io.BytesIO()
    image.save(buffer, format="PNG", optimize=True)
    filename = f"{label}-{secrets.token_hex(8)}.png"
    (branding_dir() / filename).write_bytes(buffer.getvalue())
    return filename


def branding_image_path(filename: str) -> Path | None:
    """Resolve a stored branding filename, refusing anything outside the dir."""
    if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
        return None
    path = (branding_dir() / filename).resolve()
    try:
        path.relative_to(branding_dir().resolve())
    except ValueError:
        return None
    return path if path.is_file() else None


def delete_branding_image(filename: str) -> None:
    path = branding_image_path(filename)
    if path is not None:
        path.unlink(missing_ok=True)


def media_type_for(filename: str) -> str:
    suffix = Path(filename).suffix.lower()
    for media_type, extension in ALLOWED_TYPES.items():
        if extension == suffix:
            return media_type
    return "application/octet-stream"
