"""Making an instance somebody else's.

One page, four cards, one settings group. Everything it writes goes through
`branding.stored_only`, so a field left at its shipped wording is never written
down and a later release can still improve it — the prompt-fragment rule, and
the reason this page can afford to render every flavour string as an editable
box without freezing all of them the first time somebody presses Save.

`branding.forget()` after every write, and this is the only module that calls
it. The snapshot is a process-level cache read by a Jinja global; a save that
did not drop it would take effect on the next restart, which is the shape of
failure this codebase keeps cataloguing.
"""

from __future__ import annotations

import logging

from fastapi import APIRouter, File, Form, Request, Response, UploadFile, status
from fastapi.responses import RedirectResponse

from lembas.api.deps import AdminUser, Db
from lembas.services import branding as branding_service
from lembas.services import settings_store, uploads
from lembas.web.templating import render

log = logging.getLogger(__name__)

router = APIRouter(prefix="/admin/customization", tags=["admin-branding"])

MAX_CUSTOM_CSS = 40_000

# How many custom themes an instance may keep. Not a design limit -- there is
# nothing in `theme_css` that cares -- but the whole set lives in one settings
# row read into a process-level snapshot on every render, and the page offers a
# blank block whenever there is room, so *some* number has to say when to stop
# offering. Twelve is far past what anybody wants and small enough that the
# stylesheet stays a stylesheet.
MAX_THEMES = 12


def _page(request: Request, db: Db, saved: str = "", error: str = "") -> Response:
    values = settings_store.get_group(db, branding_service.BRANDING)
    brand = branding_service.for_db(db)
    return render(
        request,
        "admin/customization.html",
        {
            "values": values,
            "current": brand,
            # The flavour table drives the form, so a string added in code
            # appears here with its default in the box and no template change.
            "flavour": [
                {
                    "key": key,
                    "label": label,
                    "hint": hint,
                    "default": default,
                    "value": str(values.get(f"text_{key}") or ""),
                }
                for key, (label, hint, default) in branding_service.FLAVOUR.items()
            ],
            "tokens": branding_service.THEME_TOKENS,
            "custom_themes": [t for t in brand.themes if not t.built_in],
            "bases": [name for name, _, _ in branding_service.BUILT_IN],
            "max_themes": MAX_THEMES,
            "saved": saved,
            "error": error,
        },
    )


@router.get("")
async def customization_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
    return _page(request, db, saved=saved)


def _write(db: Db, changes: dict) -> None:
    """Store a change and drop the cache, in that order and always together."""
    settings_store.update(db, changes, key=branding_service.BRANDING)
    branding_service.forget()


@router.post("/identity")
async def save_identity(
    request: Request,
    db: Db,
    user: AdminUser,
    instance_name: str = Form(""),
    tagline: str = Form(""),
    logo: UploadFile | None = File(None),
    favicon: UploadFile | None = File(None),
    remove_logo: bool = Form(False),
    remove_favicon: bool = Form(False),
) -> Response:
    stored = settings_store.get_group(db, branding_service.BRANDING)
    changes: dict = {
        "instance_name": instance_name.strip()[:120],
        "tagline": tagline.strip()[:200],
    }

    if remove_logo:
        for name in (stored.get("logo_path"), *(stored.get("icon_paths") or {}).values()):
            uploads.delete_branding_image(str(name or ""))
        changes["logo_path"] = ""
        changes["icon_paths"] = {}
    if remove_favicon:
        uploads.delete_branding_image(str(stored.get("favicon_path") or ""))
        changes["favicon_path"] = ""

    try:
        if logo is not None and logo.filename:
            payload = await logo.read()
            changes["logo_path"] = uploads.save_branding_image(payload, logo.content_type or "")
            # Derived here rather than on demand: a launcher asks for a 512px
            # PNG and will not scale one itself, and doing it per request would
            # mean resizing an image on the path that serves it.
            changes["icon_paths"] = uploads.derive_icons(payload)
        if favicon is not None and favicon.filename:
            payload = await favicon.read()
            changes["favicon_path"] = uploads.save_branding_image(
                payload, favicon.content_type or ""
            )
    except uploads.UploadError as exc:
        return _page(request, db, error=str(exc))

    _write(db, branding_service.stored_only(changes))
    log.info("branding identity changed by %s", user.email)
    return RedirectResponse(
        "/admin/customization?saved=Identity+saved.", status_code=status.HTTP_303_SEE_OTHER
    )


@router.post("/flavour")
async def save_flavour(request: Request, db: Db, user: AdminUser) -> Response:
    """The Middle-earth strings.

    Read from the raw form rather than declared as parameters, because the set
    is `branding.FLAVOUR` and a parameter list would be a second copy of it that
    goes stale the first time a string is added. A key that was not submitted is
    left alone; one submitted empty falls back to its default, which is what
    makes "clear the box" mean "give me the shipped wording back" rather than
    "show nothing here".
    """
    form = await request.form()
    changes = {
        f"text_{key}": str(form.get(f"text_{key}") or "").strip()[:400]
        for key in branding_service.FLAVOUR
        if f"text_{key}" in form
    }
    _write(db, branding_service.stored_only(changes))
    return RedirectResponse(
        "/admin/customization?saved=Wording+saved.", status_code=status.HTTP_303_SEE_OTHER
    )


@router.post("/css")
async def save_css(db: Db, user: AdminUser, custom_css: str = Form("")) -> Response:
    _write(db, {"custom_css": custom_css.strip()[:MAX_CUSTOM_CSS]})
    log.info("custom CSS changed by %s", user.email)
    return RedirectResponse(
        "/admin/customization?saved=Stylesheet+saved.", status_code=status.HTTP_303_SEE_OTHER
    )


@router.post("/themes")
async def save_themes(request: Request, db: Db, user: AdminUser) -> Response:
    """Every custom theme, replaced wholesale.

    One form for the lot rather than a row each, because a theme is a handful of
    colours and the whole set fits on a screen — and because replacing the list
    means a theme removed here is gone, with no reconciliation between what was
    posted and what was stored.

    Nothing is validated here beyond shape. `branding._theme_from` validates on
    every **read**, so a theme written straight into the settings table by hand,
    or stored by an earlier version, still has to produce a stylesheet that
    parses. Validating only on save would put that guarantee in the wrong place.

    The indices need not be contiguous and are not renumbered. The page renders
    one block per theme plus a blank one, so clearing an id in the middle leaves
    a gap -- and a gap is simply an index with no id, which the loop already
    skips. Renumbering would be work in aid of nothing.
    """
    form = await request.form()
    themes = []
    for index in range(_theme_count(form)):
        theme_id = str(form.get(f"theme_{index}_id") or "").strip().lower()
        if not theme_id:
            continue
        themes.append(
            {
                "id": theme_id,
                "label": str(form.get(f"theme_{index}_label") or "").strip(),
                "base": str(form.get(f"theme_{index}_base") or "moria"),
                "tokens": {
                    name: value
                    for name, _ in branding_service.THEME_TOKENS
                    if (value := str(form.get(f"theme_{index}_{name}") or "").strip())
                },
            }
        )
    # Enforced here as well as in the template, because the template's job is to
    # stop offering and this one's is to stop accepting -- a crafted POST is not
    # the page.
    themes = themes[:MAX_THEMES]
    _write(db, {"themes": themes})
    log.info("%d custom theme(s) saved by %s", len(themes), user.email)
    return RedirectResponse(
        "/admin/customization?saved=Themes+saved.", status_code=status.HTTP_303_SEE_OTHER
    )


def _theme_count(form) -> int:
    """How many theme blocks the form carried.

    Counted from the submitted keys rather than from a hidden field, so a form
    rendered by an older page still saves what it holds.
    """
    indices = [
        int(key.split("_")[1])
        for key in form
        if key.startswith("theme_") and key.split("_")[1].isdigit()
    ]
    return max(indices) + 1 if indices else 0
