"""Invitation status lifecycle (PRD §4.3, spec rsvp-flow).

    pending -> opened -> accepted -> cancelled
       |         |          |
       |         |          +-> (re-accept)
       |         +-> declined -> (re-accept)
       +-> expired (deadline passed, no response)

Two rules carry most of the weight:

* Every transition appends an `rsvp_history` row. A cancellation must not erase the fact
  that the guest once accepted — the host needs that trail.
* Headcount counts only invitations *currently* accepted, summing party_size. That is what
  makes a cancellation reduce the caterer's number the instant it happens.
"""

import uuid
from datetime import UTC, datetime

from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models import Invitation, Rsvp, RsvpHistory
from app.models.enums import HistoryActor, InvitationStatus

S = InvitationStatus

ALLOWED_TRANSITIONS: dict[InvitationStatus, frozenset[InvitationStatus]] = {
    S.PENDING: frozenset({S.OPENED, S.ACCEPTED, S.DECLINED, S.EXPIRED}),
    S.OPENED: frozenset({S.ACCEPTED, S.DECLINED, S.EXPIRED}),
    # A guest may cancel, then change their mind again — the same link keeps working.
    S.ACCEPTED: frozenset({S.CANCELLED, S.DECLINED, S.ACCEPTED}),
    S.DECLINED: frozenset({S.ACCEPTED}),
    S.CANCELLED: frozenset({S.ACCEPTED}),
    # Terminal: the deadline has passed and the page no longer offers a form.
    S.EXPIRED: frozenset(),
}

#: Statuses that contribute to a headcount.
COUNTED_STATUSES = frozenset({S.ACCEPTED})


class InvalidTransitionError(ValueError):
    """Raised when a status change is not permitted by the lifecycle."""

    def __init__(self, current: InvitationStatus, target: InvitationStatus) -> None:
        super().__init__(f"Cannot move invitation from {current.value} to {target.value}")
        self.current = current
        self.target = target


def can_transition(current: InvitationStatus, target: InvitationStatus) -> bool:
    return target in ALLOWED_TRANSITIONS.get(current, frozenset())


async def transition(
    session: AsyncSession,
    invitation: Invitation,
    target: InvitationStatus,
    *,
    actor: HistoryActor,
    source: str | None = None,
    party_size: int | None = None,
    actor_admin_id: uuid.UUID | None = None,
    now: datetime | None = None,
) -> Invitation:
    """Move an invitation to `target`, stamping timestamps and appending history.

    Raises InvalidTransitionError rather than silently ignoring an illegal move, so a bug in a
    caller surfaces immediately instead of corrupting the headcount.
    """
    current = invitation.status
    if not can_transition(current, target):
        raise InvalidTransitionError(current, target)

    moment = now or datetime.now(UTC)
    previous = current
    invitation.status = target

    if target is S.OPENED:
        invitation.opened_at = invitation.opened_at or moment
    elif target in {S.ACCEPTED, S.DECLINED}:
        invitation.responded_at = moment
        # Re-accepting clears the cancellation stamp so the record reads truthfully.
        if target is S.ACCEPTED:
            invitation.cancelled_at = None
    elif target is S.CANCELLED:
        invitation.cancelled_at = moment

    session.add(
        RsvpHistory(
            invitation_id=invitation.id,
            from_status=previous,
            to_status=target,
            party_size=party_size,
            actor=actor,
            actor_admin_id=actor_admin_id,
            source=source,
        )
    )
    return invitation


async def mark_opened(
    session: AsyncSession, invitation: Invitation, *, now: datetime | None = None
) -> Invitation:
    """Record a page view. Always increments open_count; only the first view moves status.

    Deliberately tolerant: an already-accepted guest revisiting their link must not be
    dragged backwards to `opened`.
    """
    moment = now or datetime.now(UTC)
    invitation.open_count += 1

    if invitation.status is S.PENDING:
        await transition(
            session, invitation, S.OPENED, actor=HistoryActor.GUEST, source="page_view", now=moment
        )
    elif invitation.opened_at is None:
        invitation.opened_at = moment

    return invitation


def headcount_query(event_id: uuid.UUID) -> Select[tuple[int]]:
    """Sum party_size across invitations currently accepted for one event.

    COALESCE keeps the result 0 rather than NULL when nobody has accepted yet, which
    matters because the dashboard renders the number directly.
    """
    return (
        select(func.coalesce(func.sum(Rsvp.party_size), 0))
        .select_from(Invitation)
        .join(Rsvp, Rsvp.invitation_id == Invitation.id)
        .where(
            Invitation.event_id == event_id,
            Invitation.status.in_(COUNTED_STATUSES),
        )
    )


def is_cancellable(invitation: Invitation, event_starts_at: datetime) -> bool:
    """Cancellation is blocked once the event has started (spec cancellation FR-3.7)."""
    if invitation.status is not S.ACCEPTED:
        return False
    return datetime.now(UTC) < event_starts_at


def is_rsvp_open(deadline: datetime | None, now: datetime | None = None) -> bool:
    """False once the RSVP deadline has passed; the page then shows a closed message."""
    if deadline is None:
        return True
    return (now or datetime.now(UTC)) < deadline
