"""Card designs: upload, preview, publish, roll back (tasks 5.2, 5.3, design D1-D5).

A design is uploaded as a draft, looked at, then published. Publishing is the only step that
changes what guests see, and it is separate from uploading on purpose — a designer iterating
on a card must not be pushing each attempt live.

One design is published per event at a time, enforced by a partial unique index rather than
by this module remembering to unpublish the previous one. Two admins publishing at the same
moment would otherwise both succeed and the guest page would pick arbitrarily.
"""

import uuid
from datetime import UTC, datetime
from typing import Annotated, Any, Literal

from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db import get_session
from app.models import Event
from app.models.card import EventCardDesign
from app.models.enums import CardDesignStatus, CardRenderer
from app.schemas.card import PreviewImage, preview_image_of
from app.services import audit, card_html, media, scope
from app.services.auth import CurrentAdmin, require
from app.services.policy import Action

router = APIRouter(tags=["admin"])

#: The design and everything it references, capped together. A per-file limit alone would
#: let twelve individually-compliant files add up to an unusable page.
#:
#: 600KB against the page's 800KB budget (task 9.2), not the 2MB this started at: the rest
#: of the page measures ~110KB of JavaScript plus its HTML and CSS, so a card allowed 2MB
#: could single-handedly put the invitation two and a half times over budget while the
#: upload reported success. The admin warns above 500KB; this is the refusal.
MAX_DESIGN_BYTES = 600 * 1024

_IMPLEMENTED = (CardRenderer.HTML, CardRenderer.IMAGE)


class _CardConfigBase(BaseModel):
    """What every renderer's config carries regardless of how the card itself is drawn."""

    preview_image: PreviewImage | None = None


class HtmlCardConfig(_CardConfigBase):
    """A self-contained document plus the files it references (design D3)."""

    renderer: Literal[CardRenderer.HTML] = CardRenderer.HTML
    #: The sanitised document, stored inline rather than as a file: it is rendered into the
    #: page's HTML on every request, so a filesystem read per invitation would buy nothing.
    document: str
    #: Filenames the designer referenced, in the order they were uploaded. Kept for the
    #: admin's asset list — the document itself already carries the rewritten URLs.
    #:
    #: The preview image is deliberately absent from this list: it is not a file the card
    #: references, so listing it would make the "you did not upload what your card needs"
    #: check reason about a file the card never asked for.
    asset_names: list[str] = Field(default_factory=list)


class ImageCardConfig(_CardConfigBase):
    """Flat artwork. Alt text per locale, because the card carries the invitation's words."""

    renderer: Literal[CardRenderer.IMAGE] = CardRenderer.IMAGE
    image_url: str
    alt_en: str = ""
    alt_bn: str = ""
    #: Authored aspect ratio, so the page reserves the right box before the image loads and
    #: the card does not shift the call to action down as it arrives.
    width: int | None = None
    height: int | None = None


#: Discriminated on `renderer`, so a config is parsed as exactly one shape and a document
#: cannot be read out of an image design or an image URL out of an HTML one.
CardConfig = Annotated[HtmlCardConfig | ImageCardConfig, Field(discriminator="renderer")]
_CONFIG_ADAPTER: TypeAdapter[HtmlCardConfig | ImageCardConfig] = TypeAdapter(CardConfig)


def parse_config(
    renderer: CardRenderer, config: dict[str, Any]
) -> HtmlCardConfig | ImageCardConfig:
    """Validate a stored config against its renderer.

    Called on publish rather than on read: a design whose config no longer matches its
    renderer must not become the thing every guest loads, but an existing draft in that
    state should still be listable so an admin can see it and delete it.
    """
    if renderer not in _IMPLEMENTED:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=(
                f"There is no renderer for '{renderer.value}' designs. "
                f"Supported: {', '.join(r.value for r in _IMPLEMENTED)}."
            ),
        )
    try:
        return _CONFIG_ADAPTER.validate_python({**config, "renderer": renderer.value})
    except ValidationError as exc:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=(
                f"This design's settings do not match a {renderer.value} card: "
                f"{exc.error_count()} problem(s)."
            ),
        ) from exc


class CardDesignRead(BaseModel):
    id: uuid.UUID
    event_id: uuid.UUID
    renderer: CardRenderer
    status: CardDesignStatus
    version: int
    config: dict[str, Any]
    assets: dict[str, str]
    #: Combined on-disk size of the design's assets, for the budget warning.
    total_bytes: int
    #: The design's link-preview image, lifted out of `config` so the admin panel can show
    #: whether this version has one without knowing each renderer's config shape.
    preview_image: PreviewImage | None
    #: Non-fatal notes about an accepted upload — an off-ratio preview image, so far. Empty
    #: on every read that is not the response to an upload.
    warnings: list[str] = Field(default_factory=list)
    published_at: datetime | None
    created_at: datetime


def _client_ip(request: Request) -> str | None:
    fwd = request.headers.get("x-forwarded-for")
    return fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else None)


def _stored_urls(design: EventCardDesign) -> list[str]:
    """Every file on disk this design owns — its companions and its preview image."""
    urls = list(design.assets.values())
    preview = preview_image_of(design.config)
    if preview is not None:
        urls.append(preview.url)
    return urls


def _to_read(design: EventCardDesign, warnings: list[str] | None = None) -> CardDesignRead:
    return CardDesignRead(
        id=design.id,
        event_id=design.event_id,
        renderer=design.renderer,
        status=design.status,
        version=design.version,
        config=design.config,
        assets=design.assets,
        total_bytes=media.total_bytes(_stored_urls(design)),
        preview_image=preview_image_of(design.config),
        warnings=warnings or [],
        published_at=design.published_at,
        created_at=design.created_at,
    )


async def _load_event(session: AsyncSession, event_id: uuid.UUID, admin: CurrentAdmin) -> Event:
    return await scope.scoped_event(session, admin, event_id)


async def _load_design(
    session: AsyncSession, design_id: uuid.UUID, admin: CurrentAdmin
) -> EventCardDesign:
    """A card design the caller may reach, found through the event that owns it.

    The design's own id says nothing about who may touch it; its event does. Joining rather
    than fetching-then-checking keeps the out-of-scope answer identical to the not-found one.
    """
    design = await session.scalar(
        select(EventCardDesign)
        .join(Event, Event.id == EventCardDesign.event_id)
        .where(EventCardDesign.id == design_id, scope.visible_events(admin))
    )
    if design is None:
        raise scope.not_found()
    return design


@router.get("/admin/events/{event_id}/card-designs", response_model=list[CardDesignRead])
async def list_designs(
    event_id: uuid.UUID,
    admin: CurrentAdmin = Depends(require(Action.VIEW_DASHBOARD)),
    session: AsyncSession = Depends(get_session),
) -> list[CardDesignRead]:
    """Newest first, so the current version and the one to roll back to are both at hand."""
    await _load_event(session, event_id, admin)
    rows = await session.scalars(
        select(EventCardDesign)
        .where(EventCardDesign.event_id == event_id)
        .order_by(EventCardDesign.version.desc())
    )
    return [_to_read(d) for d in rows]


@router.post(
    "/admin/events/{event_id}/card-designs",
    response_model=CardDesignRead,
    status_code=status.HTTP_201_CREATED,
)
async def upload_design(
    event_id: uuid.UUID,
    request: Request,
    document: Annotated[UploadFile, File(description="The card's HTML file")],
    assets: Annotated[
        list[UploadFile] | None, File(description="Every file the card references")
    ] = None,
    og_image: Annotated[
        UploadFile | None,
        File(description="1200x630 PNG or JPEG shown when a link to this event is shared"),
    ] = None,
    alt_en: Annotated[str, Form()] = "",
    alt_bn: Annotated[str, Form()] = "",
    admin: CurrentAdmin = Depends(require(Action.EDIT_CONTENT)),
    session: AsyncSession = Depends(get_session),
) -> CardDesignRead:
    """Upload a card and its companion files together, as one draft.

    Together and not separately: the document's references can only be rewritten against
    files that are already stored, so an upload split in two would need an intermediate
    state where a design exists with references pointing nowhere.
    """
    await _load_event(session, event_id, admin)

    raw = await document.read()
    if not raw:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="The card file is empty."
        )

    #: Read and validated before the companions so its bytes are already in the budget when
    #: the first over-budget refusal is worded — the total an admin is shown has to describe
    #: everything the event will actually store.
    preview: media.StoredPreview | None = None
    if og_image is not None:
        og_data = await og_image.read()
        if og_data:
            try:
                preview = media.save_preview(og_data, og_image.content_type or "", event_id)
            except media.MediaRejectedError as exc:
                raise HTTPException(
                    status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
                    detail=f"{og_image.filename or 'preview image'}: {exc}",
                ) from exc

    #: Uploaded filename -> stored URL. The same map both rewrites the document's
    #: references and becomes the design's asset list. The preview image is deliberately
    #: absent from it (design D2).
    stored: dict[str, str] = {}
    total = len(raw) + (preview.byte_size if preview is not None else 0)
    for upload in assets or []:
        data = await upload.read()
        total += len(data)
        if total > MAX_DESIGN_BYTES:
            raise HTTPException(
                status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
                detail=(
                    f"The design and its files come to {total / 1_048_576:.1f} MB, over the "
                    f"{MAX_DESIGN_BYTES / 1_048_576:.1f} MB budget for one card."
                ),
            )
        try:
            asset = media.save(data, upload.content_type or "", event_id)
        except media.MediaRejectedError as exc:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
                detail=f"{upload.filename}: {exc}",
            ) from exc
        # Only the filename identifies a companion: browsers send `folder/file.svg` for a
        # directory upload, while the document references the bare name.
        stored[(upload.filename or "").rsplit("/", 1)[-1]] = asset.url

    try:
        cleaned = card_html.sanitise(raw.decode("utf-8", errors="replace"), stored)
    except card_html.CardRejectedError as exc:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
        ) from exc

    if cleaned.missing:
        # Blocks the upload rather than the publish: telling the designer now, while they
        # still have the export open, costs them one retry instead of a round trip.
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=(
                "The card references "
                + ", ".join(cleaned.missing)
                + ", which were not uploaded with it. Add them and upload again."
            ),
        )

    next_version = (
        await session.scalar(
            select(func.coalesce(func.max(EventCardDesign.version), 0) + 1).where(
                EventCardDesign.event_id == event_id
            )
        )
    ) or 1

    design = EventCardDesign(
        event_id=event_id,
        renderer=CardRenderer.HTML,
        status=CardDesignStatus.DRAFT,
        version=int(next_version),
        config=HtmlCardConfig(
            document=cleaned.html,
            asset_names=sorted(stored),
            preview_image=(
                PreviewImage(
                    url=preview.url,
                    width=preview.width,
                    height=preview.height,
                    byte_size=preview.byte_size,
                    content_type=preview.content_type,
                )
                if preview is not None
                else None
            ),
        ).model_dump(mode="json"),
        assets=stored,
        created_by_admin_id=admin.id,
    )
    session.add(design)
    await session.flush()

    audit.record(
        session,
        action=audit.Actions.CARD_UPLOAD,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="event_card_design",
        entity_id=design.id,
        after={
            "event_id": str(event_id),
            "version": design.version,
            "assets": len(stored),
            # Answers "why did this event's links stop showing a picture" from the log alone.
            "preview_image": preview is not None,
            "removed": cleaned.removed,
        },
        ip=_client_ip(request),
    )
    warnings = [preview.warning] if preview is not None and preview.warning else []
    return _to_read(design, warnings)


@router.post("/admin/card-designs/{design_id}/publish", response_model=CardDesignRead)
async def publish_design(
    design_id: uuid.UUID,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.EDIT_CONTENT)),
    session: AsyncSession = Depends(get_session),
) -> CardDesignRead:
    """Make this design the one guests see, demoting whatever was published before.

    The demotion happens first and is flushed before the promotion, because the partial
    unique index does not care that both writes are in one transaction — two published rows
    at any point inside it is a violation.
    """
    design = await _load_design(session, design_id, admin)

    parse_config(design.renderer, design.config or {})

    current = await session.scalar(
        select(EventCardDesign).where(
            EventCardDesign.event_id == design.event_id,
            EventCardDesign.status == CardDesignStatus.PUBLISHED,
            EventCardDesign.id != design.id,
        )
    )
    if current is not None:
        current.status = CardDesignStatus.DRAFT
        current.published_at = None
        await session.flush()

    design.status = CardDesignStatus.PUBLISHED
    design.published_at = datetime.now(UTC)
    await session.flush()

    audit.record(
        session,
        action=audit.Actions.CARD_PUBLISH,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="event_card_design",
        entity_id=design.id,
        before={"replaced_version": current.version if current else None},
        after={
            "event_id": str(design.event_id),
            "version": design.version,
            "preview_image": preview_image_of(design.config) is not None,
        },
        ip=_client_ip(request),
    )
    return _to_read(design)


@router.post("/admin/card-designs/{design_id}/unpublish", response_model=CardDesignRead)
async def unpublish_design(
    design_id: uuid.UUID,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.EDIT_CONTENT)),
    session: AsyncSession = Depends(get_session),
) -> CardDesignRead:
    """Take the card off the invitation. The page falls back to its plain composition."""
    design = await _load_design(session, design_id, admin)

    design.status = CardDesignStatus.DRAFT
    design.published_at = None
    await session.flush()

    audit.record(
        session,
        action=audit.Actions.CARD_UNPUBLISH,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="event_card_design",
        entity_id=design.id,
        after={"event_id": str(design.event_id), "version": design.version},
        ip=_client_ip(request),
    )
    return _to_read(design)


@router.delete("/admin/card-designs/{design_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_design(
    design_id: uuid.UUID,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.EDIT_CONTENT)),
    session: AsyncSession = Depends(get_session),
) -> None:
    """Delete a draft. A published design must be unpublished first.

    Its files are left on disk. They are content-addressed, so another version of the same
    card may well reference the identical bytes — deleting them here would blank a live card
    to reclaim a few kilobytes.
    """
    design = await _load_design(session, design_id, admin)
    if design.status is CardDesignStatus.PUBLISHED:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="This design is live. Unpublish it before deleting it.",
        )

    audit.record(
        session,
        action=audit.Actions.CARD_DELETE,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="event_card_design",
        entity_id=design.id,
        before={"event_id": str(design.event_id), "version": design.version},
        ip=_client_ip(request),
    )
    await session.delete(design)
