"""Guest-facing invitation reads (task 2.2, spec invitation-page FR-1.1, FR-1.2).

Enumeration defence: an unknown token and a token belonging to an unpublished event both
produce the same generic 404. The response must never reveal whether a token exists.
"""

import logging
import uuid

from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.config import get_settings
from app.db import get_session
from app.models import Event, Invitation, Wedding
from app.models.card import EventCardDesign
from app.models.enums import CardDesignStatus, CardRenderer, InvitationType, Locale
from app.schemas.invitation import (
    CardRead,
    CoupleRead,
    CurrentRsvpRead,
    EventRead,
    GreetingRead,
    GuestPrefill,
    LinkPreviewRead,
    OpenEventRead,
    PreviewImageRead,
    TokenInvitationRead,
)
from app.services import greeting, lifecycle, link_preview
from app.services.tokens import mask_token, normalize_short_code

logger = logging.getLogger(__name__)
router = APIRouter(tags=["invitation"])

NOT_FOUND_DETAIL = "Invitation not found"


async def _published_design(session: AsyncSession, event_id: uuid.UUID) -> EventCardDesign | None:
    """The event's published design, if it has one.

    Read here rather than joined onto the event so the absence of a card costs one cheap
    indexed lookup and the presence of one costs the same — every invitation page runs this,
    including the hundreds that arrive at once after a bulk send.

    One query serves both the card and the link preview: the picture a link unfurls with
    ships inside the same design as the card it represents (design D2), so fetching them
    separately would be two round trips for one row.
    """
    design: EventCardDesign | None = await session.scalar(
        select(EventCardDesign).where(
            EventCardDesign.event_id == event_id,
            EventCardDesign.status == CardDesignStatus.PUBLISHED,
        )
    )
    return design


def _card(design: EventCardDesign | None) -> CardRead | None:
    if design is None:
        return None

    config = design.config or {}
    return CardRead(
        renderer=design.renderer,
        document=config.get("document") if design.renderer is CardRenderer.HTML else None,
        image_url=config.get("image_url"),
        alt_en=config.get("alt_en", ""),
        alt_bn=config.get("alt_bn", ""),
        width=config.get("width"),
        height=config.get("height"),
    )


def _preview(event: Event, wedding: Wedding, design: EventCardDesign | None) -> LinkPreviewRead:
    """The guest-free link preview for both routes (design D3, D5).

    `for_event` and not `for_guest`, on every route in this module — including the token
    one. What comes back here is served into meta tags that a third party fetches and
    caches, so it has to be the same for every guest of the event.
    """
    built = link_preview.for_event(
        event,
        wedding,
        base_url=get_settings().app_base_url,
        card_config=design.config if design is not None else None,
    )
    return LinkPreviewRead(
        title=built.title,
        description=built.description,
        canonical_url=built.canonical_url,
        site_name=built.site_name,
        image=(
            PreviewImageRead(
                url=built.image.url, width=built.image.width, height=built.image.height
            )
            if built.image is not None
            else None
        ),
    )


def _greeting(event: Event, wedding: Wedding, invitation_type: InvitationType) -> GreetingRead:
    """Resolve both locales up front (task 4.3).

    Resolution happens here, not in the browser: the defaults, the fallback order and the
    customer's overrides are all server state, and re-deriving them client-side would mean
    two implementations that drift. It also keeps the greeting in the initial HTML, which
    PRD §9.2 requires of every piece of invitation text.

    The event's own messages come first and the wedding's are the fallback (design D10), so a
    ceremony can carry its own wording without every other one following it. Only English is
    editable per event today; Bangla simply misses the first layer and lands on the wedding's,
    which is why `resolve` must not fall back across locales.
    """
    layers = (event.invitation_messages, wedding.invitation_messages)
    return GreetingRead(
        invitation_type=invitation_type,
        message_en=greeting.resolve(*layers, locale=Locale.EN, invitation_type=invitation_type),
        message_bn=greeting.resolve(*layers, locale=Locale.BN, invitation_type=invitation_type),
    )


def _not_found() -> HTTPException:
    """One generic error for every failure mode on this route."""
    return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=NOT_FOUND_DETAIL)


@router.get("/invitations/by-token/{token}", response_model=TokenInvitationRead)
async def read_invitation_by_token(
    token: str,
    response: Response,
    preview: bool = False,
    session: AsyncSession = Depends(get_session),
) -> TokenInvitationRead:
    """Personalised invitation. Records the open, then returns the page payload.

    `preview=1` returns the identical payload without recording the open (design D9). It is
    for the case where the fetch is a link-preview crawler rather than a guest: an admin
    pasting an invitation into WhatsApp would otherwise have the platform's crawler mark
    that guest's invitation opened before the guest had seen it.

    Only the recording is skipped. Nothing about what is returned changes, so this cannot
    become a way to read an invitation that the normal route would refuse.
    """
    # Personalised and token-bearing: never cache, anywhere, by anyone.
    response.headers["Cache-Control"] = "private, no-store"
    response.headers["Referrer-Policy"] = "no-referrer"

    invitation = await session.scalar(
        select(Invitation)
        .where(Invitation.token == token)
        .options(
            selectinload(Invitation.guest),
            selectinload(Invitation.rsvp),
            selectinload(Invitation.event).selectinload(Event.wedding),
        )
    )

    if invitation is None:
        logger.info("invitation lookup miss for token %s", mask_token(token))
        raise _not_found()

    if not invitation.event.is_published:
        # Told apart in the log, never in the response. The guest still gets the one generic
        # 404 the enumeration defence requires; the operator gets the difference between "no
        # such token" and "the event is still a draft", which are the same page and entirely
        # different problems — the second is a checkbox, and reads as a broken link.
        logger.info(
            "invitation %s is for unpublished event %s; serving 404",
            mask_token(token),
            invitation.event_id,
        )
        raise _not_found()

    if not preview:
        await lifecycle.mark_opened(session, invitation)

    event = invitation.event
    wedding = event.wedding
    design = await _published_design(session, event.id)

    # No sibling-invitation lookup. It used to find the same guest's other events, which was
    # meaningful while a guest was global; now a guest belongs to one event (design D11) and
    # the only way to reach "their" other invitation would be to match on phone or email
    # across events — an identity guess this route must not make with a bearer token (3.7).

    return TokenInvitationRead(
        invitation_id=invitation.id,
        status=invitation.status,
        max_guests=invitation.max_guests,
        short_code=invitation.short_code,
        event=EventRead.model_validate(event),
        couple=CoupleRead.model_validate(wedding),
        guest=(
            GuestPrefill(
                full_name=invitation.guest.full_name,
                email=invitation.guest.email,
                phone_e164=invitation.guest.phone_e164,
                preferred_locale=invitation.guest.preferred_locale,
            )
            if invitation.guest is not None
            else None
        ),
        greeting=_greeting(
            event,
            wedding,
            invitation.guest.invitation_type if invitation.guest else InvitationType.SINGLE,
        ),
        card=_card(design),
        preview=_preview(event, wedding, design),
        current_rsvp=(
            CurrentRsvpRead.model_validate(invitation.rsvp) if invitation.rsvp is not None else None
        ),
        rsvp_open=lifecycle.is_rsvp_open(event.rsvp_deadline),
        can_cancel=lifecycle.is_cancellable(invitation, event.starts_at),
    )


@router.get("/invitations/by-code/{short_code}", response_model=TokenInvitationRead)
async def read_invitation_by_short_code(
    short_code: str,
    response: Response,
    preview: bool = False,
    session: AsyncSession = Depends(get_session),
) -> TokenInvitationRead:
    """Printed-card fallback: 'Or visit domain.com/rsvp and enter code A7K2M9'
    (spec qr-codes FR-7.5).

    Someone typing a code off a printed card is a guest by definition, so `preview` is
    carried through only to keep the two routes one behaviour rather than two.
    """
    code = normalize_short_code(short_code)
    invitation = await session.scalar(
        select(Invitation).where(Invitation.short_code == code).limit(1)
    )
    if invitation is None:
        raise _not_found()
    return await read_invitation_by_token(invitation.token, response, preview, session)


@router.get("/events/{slug}", response_model=OpenEventRead)
async def read_open_event(
    slug: str,
    response: Response,
    session: AsyncSession = Depends(get_session),
) -> OpenEventRead:
    """Open/QR route. Public and identical for every visitor, so it may be cached.

    Carries no personal data — a walk-in is unknown until they register.
    """
    response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=300"

    event = await session.scalar(
        select(Event)
        .where(Event.slug == slug, Event.is_published.is_(True))
        .options(selectinload(Event.wedding))
    )
    if event is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found")

    design = await _published_design(session, event.id)

    siblings = await session.scalars(
        select(Event)
        .where(Event.wedding_id == event.wedding_id, Event.is_published.is_(True))
        .order_by(Event.starts_at)
    )

    return OpenEventRead(
        event=EventRead.model_validate(event),
        couple=CoupleRead.model_validate(event.wedding),
        # No guest is known here, so the single sentence is the honest one — and it renders
        # with no name line above it (spec invitation-presentation).
        greeting=_greeting(event, event.wedding, InvitationType.SINGLE),
        card=_card(design),
        preview=_preview(event, event.wedding, design),
        rsvp_open=lifecycle.is_rsvp_open(event.rsvp_deadline),
        selectable_events=[EventRead.model_validate(e) for e in siblings],
    )
