"""RSVP domain service (tasks 2.7-2.11).

All write paths funnel through here so the lifecycle rules, history writes and guest
write-back happen in exactly one place. Routers stay thin.
"""

import hashlib
import uuid
from datetime import UTC, datetime

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

from app.config import get_settings
from app.models import Event, Guest, Invitation, Rsvp
from app.models.enums import (
    EventType,
    GuestSource,
    HistoryActor,
    InvitationStatus,
    InvitedVia,
    RsvpResponse,
)
from app.services import lifecycle
from app.services.phone import normalize_email
from app.services.tokens import allocate_short_code, allocate_token


async def _get_rsvp(session: AsyncSession, invitation: Invitation) -> Rsvp | None:
    """Load the current answer by explicit query rather than relationship access.

    Touching `invitation.rsvp` triggers a lazy load, which raises MissingGreenlet under the
    async engine whenever the relationship was not eagerly loaded — as happens for
    invitations created moments earlier in the open-link flow. An explicit select behaves
    identically on every path.
    """
    rsvp: Rsvp | None = await session.scalar(
        select(Rsvp).where(Rsvp.invitation_id == invitation.id)
    )
    return rsvp


def hash_ip(ip: str | None) -> str | None:
    """Store a hash, never the address itself — it is kept for abuse detection only."""
    if not ip:
        return None
    pepper = get_settings().token_pepper
    return hashlib.sha256(f"{pepper}:{ip}".encode()).hexdigest()


async def accept(
    session: AsyncSession,
    invitation: Invitation,
    *,
    name: str,
    phone: str | None,
    email: str | None,
    party_size: int,
    adults: int | None = None,
    children: int | None = None,
    message_to_couple: str | None = None,
    dietary_notes: str | None = None,
    ip: str | None = None,
    user_agent: str | None = None,
    actor: HistoryActor = HistoryActor.GUEST,
    actor_admin_id: uuid.UUID | None = None,
    source: str = "rsvp_form",
) -> Rsvp:
    """Record an acceptance.

    Idempotent by construction (FR-2.9): there is one RSVP row per invitation, so a
    double-tapped submit updates the existing answer rather than creating a second one.
    """
    party_size = max(1, min(party_size, invitation.max_guests))
    email = normalize_email(email)
    now = datetime.now(UTC)

    await lifecycle.transition(
        session,
        invitation,
        InvitationStatus.ACCEPTED,
        actor=actor,
        source=source,
        party_size=party_size,
        actor_admin_id=actor_admin_id,
        now=now,
    )

    rsvp = await _get_rsvp(session, invitation)
    if rsvp is None:
        rsvp = Rsvp(invitation_id=invitation.id, responded_at=now)
        session.add(rsvp)

    rsvp.response = RsvpResponse.ACCEPTED
    rsvp.name = name
    rsvp.email = email
    rsvp.phone_e164 = phone
    rsvp.party_size = party_size
    rsvp.adults = adults
    rsvp.children = children
    rsvp.message_to_couple = message_to_couple
    rsvp.dietary_notes = dietary_notes
    rsvp.responded_at = now
    rsvp.ip_hash = hash_ip(ip)
    rsvp.user_agent = user_agent

    # Corrections the guest makes on the form update the guest record (FR-2.5).
    if invitation.guest is not None:
        _write_back_to_guest(invitation.guest, name=name, phone=phone, email=email)

    return rsvp


def _write_back_to_guest(guest: Guest, *, name: str, phone: str | None, email: str | None) -> None:
    """Apply guest corrections without ever blanking a known value with an empty one."""
    if name:
        guest.full_name = name
    if phone:
        guest.phone_e164 = phone
        if not guest.whatsapp_phone_e164:
            guest.whatsapp_phone_e164 = phone
    if email:
        guest.email = email
        # A corrected address deserves a fresh chance at delivery.
        guest.email_invalid = False


async def decline(
    session: AsyncSession,
    invitation: Invitation,
    *,
    reason: str | None = None,
    actor: HistoryActor = HistoryActor.GUEST,
    actor_admin_id: uuid.UUID | None = None,
) -> Rsvp:
    """Record a decline. No form required (FR-2.6)."""
    now = datetime.now(UTC)
    await lifecycle.transition(
        session,
        invitation,
        InvitationStatus.DECLINED,
        actor=actor,
        source="decline",
        actor_admin_id=actor_admin_id,
        now=now,
    )

    rsvp = await _get_rsvp(session, invitation)
    if rsvp is None:
        guest_name = invitation.guest.full_name if invitation.guest is not None else "Guest"
        rsvp = Rsvp(invitation_id=invitation.id, name=guest_name, responded_at=now)
        session.add(rsvp)

    rsvp.response = RsvpResponse.DECLINED
    rsvp.party_size = 0
    rsvp.cancel_reason = reason
    rsvp.responded_at = now
    return rsvp


async def cancel(
    session: AsyncSession,
    invitation: Invitation,
    *,
    reason: str | None = None,
    actor: HistoryActor = HistoryActor.GUEST,
    actor_admin_id: uuid.UUID | None = None,
) -> Rsvp | None:
    """Cancel an acceptance.

    The headcount drops immediately because it counts only currently-accepted invitations.
    Skipping future reminder jobs happens in Phase 4 (task 5.3), where the sender
    re-checks live status before every send — that recheck is what makes cancellation
    take effect even for jobs already queued.
    """
    now = datetime.now(UTC)
    await lifecycle.transition(
        session,
        invitation,
        InvitationStatus.CANCELLED,
        actor=actor,
        source="cancel_link",
        actor_admin_id=actor_admin_id,
        now=now,
    )

    rsvp = await _get_rsvp(session, invitation)
    if rsvp is not None:
        rsvp.response = RsvpResponse.CANCELLED
        rsvp.cancel_reason = reason
    return rsvp


async def find_existing_guest(
    session: AsyncSession, event_id: uuid.UUID, *, phone: str | None, email: str | None
) -> Guest | None:
    """Match a submission to a guest already on THIS event's list (FR-2.11).

    Scoped to the event, not the wedding (design D11). The same person legitimately holds a
    record under each event they are invited to, so a match on another event's list is not a
    duplicate and must not merge — merging across events is precisely the cross-event
    identity that the event-scoped model gives up.
    """
    conditions = []
    if phone:
        conditions.append(Guest.phone_e164 == phone)
    if email:
        conditions.append(func.lower(Guest.email) == email.lower())
    if not conditions:
        return None

    guest: Guest | None = await session.scalar(
        select(Guest)
        .where(Guest.event_id == event_id, Guest.is_deleted.is_(False), or_(*conditions))
        .limit(1)
    )
    return guest


async def register_open_guest(
    session: AsyncSession,
    event: Event,
    *,
    name: str,
    phone: str,
    email: str | None,
    invited_via: InvitedVia,
) -> tuple[Guest, bool, Invitation]:
    """Create or merge a guest on ONE event's list and ensure their invitation.

    One event per call because a guest record belongs to exactly one event (design D11). A
    walk-in who ticks two ceremonies therefore becomes two records — the caller loops, and
    each pass merges independently against that event's own list.

    Returns the guest, whether it merged into a record already on this event, and the
    invitation.
    """
    email = normalize_email(email)
    guest = await find_existing_guest(session, event.id, phone=phone, email=email)
    merged = guest is not None

    if guest is None:
        guest = Guest(
            wedding_id=event.wedding_id,
            event_id=event.id,
            full_name=name,
            phone_e164=phone,
            whatsapp_phone_e164=phone,
            email=email,
            source=GuestSource.SELF_REGISTERED,
            # Load-free empty collection: assigning to a relationship later would make
            # SQLAlchemy read the current one back, and that lazy SELECT is illegal here.
            invitations=[],
        )
        session.add(guest)
        await session.flush()
    else:
        _write_back_to_guest(guest, name=name, phone=phone, email=email)

    existing = await session.scalar(
        select(Invitation)
        .where(Invitation.event_id == event.id, Invitation.guest_id == guest.id)
        .limit(1)
    )
    if existing is not None:
        return guest, merged, existing

    invitation = Invitation(
        event_id=event.id,
        guest_id=guest.id,
        token=await allocate_token(session),
        short_code=await allocate_short_code(session),
        status=InvitationStatus.OPENED,
        # Nobody set a ceiling for a walk-in, and the model default of 1 would clamp
        # "two of us are coming" down to one head without telling anybody.
        max_guests=get_settings().open_link_max_guests,
        invited_via=invited_via,
        opened_at=datetime.now(UTC),
        open_count=1,
    )
    session.add(invitation)
    await session.flush()
    return guest, merged, invitation


def build_urls(base_url: str, token: str) -> tuple[str, str]:
    """Invitation and cancel URLs. Kept short so an SMS stays within one segment in v2."""
    root = base_url.rstrip("/")
    return f"{root}/i/{token}", f"{root}/i/{token}/cancel"


def build_unsubscribe_url(base_url: str, token: str) -> str:
    """Opt-out URL for one event's record (task 3.6).

    Short path on purpose — this link rides in the footer of every email, where length
    costs both SMS segments in v2 and legibility in clients that show the raw URL.
    """
    return f"{base_url.rstrip('/')}/u/{token}"


def resolve_events(all_events: list[Event], wanted: list[EventType]) -> list[Event]:
    """Map the open form's checkboxes to published events, ignoring anything unknown."""
    wanted_set = set(wanted)
    return [e for e in all_events if e.type in wanted_set and e.is_published]
