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

These tests encode the rules that decide what the caterer is told, so they are worth being
fussy about.
"""

from datetime import UTC, datetime, timedelta

import pytest

from app.models import Invitation
from app.models.enums import HistoryActor, InvitationStatus
from app.services.lifecycle import (
    COUNTED_STATUSES,
    InvalidTransitionError,
    can_transition,
    is_cancellable,
    is_rsvp_open,
    mark_opened,
    transition,
)

S = InvitationStatus


class FakeSession:
    """Collects added rows so history writes can be asserted without a database."""

    def __init__(self) -> None:
        self.added: list[object] = []

    def add(self, obj: object) -> None:
        self.added.append(obj)


def make_invitation(status: InvitationStatus = S.PENDING) -> Invitation:
    inv = Invitation()
    inv.status = status
    inv.open_count = 0
    inv.opened_at = None
    inv.responded_at = None
    inv.cancelled_at = None
    return inv


@pytest.mark.parametrize(
    ("current", "target"),
    [
        (S.PENDING, S.OPENED),
        (S.PENDING, S.ACCEPTED),
        (S.OPENED, S.ACCEPTED),
        (S.OPENED, S.DECLINED),
        (S.ACCEPTED, S.CANCELLED),
        # A guest who changes their mind twice keeps the same working link.
        (S.CANCELLED, S.ACCEPTED),
        (S.DECLINED, S.ACCEPTED),
    ],
)
def test_permitted_transitions(current: InvitationStatus, target: InvitationStatus) -> None:
    assert can_transition(current, target)


@pytest.mark.parametrize(
    ("current", "target"),
    [
        # Revisiting an accepted invitation must not drag it backwards.
        (S.ACCEPTED, S.OPENED),
        (S.ACCEPTED, S.PENDING),
        (S.CANCELLED, S.DECLINED),
        (S.EXPIRED, S.ACCEPTED),
        (S.DECLINED, S.CANCELLED),
    ],
)
def test_forbidden_transitions(current: InvitationStatus, target: InvitationStatus) -> None:
    assert not can_transition(current, target)


async def test_transition_writes_history() -> None:
    """A cancellation must not erase the fact that they once accepted."""
    session = FakeSession()
    inv = make_invitation(S.ACCEPTED)

    await transition(session, inv, S.CANCELLED, actor=HistoryActor.GUEST, source="cancel_link")  # type: ignore[arg-type]

    assert inv.status is S.CANCELLED
    assert inv.cancelled_at is not None
    assert len(session.added) == 1
    history = session.added[0]
    assert history.from_status is S.ACCEPTED  # type: ignore[attr-defined]
    assert history.to_status is S.CANCELLED  # type: ignore[attr-defined]
    assert history.source == "cancel_link"  # type: ignore[attr-defined]


async def test_illegal_transition_raises_rather_than_silently_passing() -> None:
    session = FakeSession()
    inv = make_invitation(S.EXPIRED)

    with pytest.raises(InvalidTransitionError):
        await transition(session, inv, S.ACCEPTED, actor=HistoryActor.GUEST)  # type: ignore[arg-type]

    assert inv.status is S.EXPIRED
    assert session.added == []


async def test_re_accepting_clears_the_cancellation_stamp() -> None:
    session = FakeSession()
    inv = make_invitation(S.ACCEPTED)
    await transition(session, inv, S.CANCELLED, actor=HistoryActor.GUEST)  # type: ignore[arg-type]
    assert inv.cancelled_at is not None

    await transition(session, inv, S.ACCEPTED, actor=HistoryActor.GUEST, source="re_accept")  # type: ignore[arg-type]

    assert inv.status is S.ACCEPTED
    assert inv.cancelled_at is None
    assert len(session.added) == 2  # the full trail is preserved


async def test_opening_counts_every_view_but_only_transitions_once() -> None:
    session = FakeSession()
    inv = make_invitation(S.PENDING)

    await mark_opened(session, inv)  # type: ignore[arg-type]
    assert inv.status is S.OPENED
    assert inv.open_count == 1
    first_opened_at = inv.opened_at

    await mark_opened(session, inv)  # type: ignore[arg-type]
    assert inv.status is S.OPENED
    assert inv.open_count == 2
    assert inv.opened_at == first_opened_at
    assert len(session.added) == 1  # no second history row


async def test_opening_does_not_regress_an_accepted_invitation() -> None:
    """FR-1.12: a returning accepted guest sees their answer, not a blank form."""
    session = FakeSession()
    inv = make_invitation(S.ACCEPTED)

    await mark_opened(session, inv)  # type: ignore[arg-type]

    assert inv.status is S.ACCEPTED
    assert inv.open_count == 1
    assert session.added == []


def test_only_accepted_invitations_count_toward_headcount() -> None:
    """Cancelled and declined guests must not reach the caterer's number."""
    assert frozenset({S.ACCEPTED}) == COUNTED_STATUSES
    for status in (S.PENDING, S.OPENED, S.DECLINED, S.CANCELLED, S.EXPIRED):
        assert status not in COUNTED_STATUSES


def test_cancellation_is_blocked_once_the_event_starts() -> None:
    """FR-3.7: after the start time the page shows a contact number instead."""
    inv = make_invitation(S.ACCEPTED)
    future = datetime.now(UTC) + timedelta(hours=2)
    past = datetime.now(UTC) - timedelta(hours=2)

    assert is_cancellable(inv, future)
    assert not is_cancellable(inv, past)


def test_only_accepted_invitations_can_be_cancelled() -> None:
    future = datetime.now(UTC) + timedelta(days=1)
    for status in (S.PENDING, S.OPENED, S.DECLINED, S.CANCELLED, S.EXPIRED):
        assert not is_cancellable(make_invitation(status), future)


def test_rsvp_closes_after_the_deadline() -> None:
    """FR-1.11: past the deadline the form is hidden, not merely ignored."""
    now = datetime.now(UTC)
    assert is_rsvp_open(now + timedelta(days=1), now)
    assert not is_rsvp_open(now - timedelta(seconds=1), now)
    assert is_rsvp_open(None, now)  # no deadline configured
