"""Event settings, including the date change that re-plans reminders (task 5.5).

Moving `starts_at` is the single most dangerous edit in the admin surface. Every reminder
wave is computed from it, and hundreds of jobs are already sitting in the queue pointing at
the old date. So the PATCH does not just write the column: it re-points the queued jobs and
tells the caller whether the change is one guests need to hear about.
"""

import uuid
from datetime import UTC, datetime

from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.db import get_session
from app.models import AdminUser, Event, Guest, Invitation, Rsvp, Wedding
from app.models.enums import AdminStatus, EventType
from app.services import audit, greeting, phone, planner, scope, slugs
from app.services.auth import CurrentAdmin, require
from app.services.policy import Action

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


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)


class AdminEventRead(BaseModel):
    """Named distinctly from the public `EventRead` on purpose.

    Two Pydantic models sharing a class name make FastAPI fully-qualify *both* in the
    OpenAPI document — `app__schemas__invitation__EventRead` — which silently renames the
    generated TypeScript type the public invitation pages were already built against.
    """

    id: uuid.UUID
    type: EventType
    slug: str
    title_en: str
    title_bn: str
    starts_at: datetime
    ends_at: datetime | None
    venue_name: str
    venue_address: str
    map_url: str | None
    dress_code: str | None
    notes: str | None
    host_name_1: str
    host_name_2: str | None
    host_phone: str
    rsvp_deadline: datetime | None
    capacity: int | None
    is_published: bool
    theme_key: str
    #: Greeting overrides for this event; empty means it follows the wedding-wide setting.
    invitation_messages: dict[str, dict[str, str]]


def _clean_host_fields(fields: dict[str, object]) -> None:
    """Normalise the host columns in place, on whichever write path is calling.

    Shared by create and update so the two cannot drift: a number rejected on create has to
    be rejected on edit, and "no second host" has to mean NULL on both (design D3, D4).

    Only keys actually present are touched. On a PATCH that is what makes "not sent" mean
    "leave alone" — mapping an absent key to a cleared column would blank the host every time
    an admin moved the date.
    """
    if "host_name_1" in fields:
        fields["host_name_1"] = _require_host_name(fields["host_name_1"])

    if "host_name_2" in fields:
        # Blank collapses to NULL so absent has exactly one representation.
        raw = fields["host_name_2"]
        cleaned = raw.strip() if isinstance(raw, str) else None
        fields["host_name_2"] = cleaned or None

    if "host_phone" in fields:
        fields["host_phone"] = _require_host_phone(fields["host_phone"])


def _require_host_name(raw: object) -> str:
    """The primary host name is printed on every invitation for the event, so an empty one is
    refused rather than stored — the column is NOT NULL and a blank would render an unlabelled
    host block to guests."""
    cleaned = raw.strip() if isinstance(raw, str) else ""
    if not cleaned:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail={
                "code": "host_name_required",
                "field": "host_name_1",
                "message": "A host name is required — guests see it on the invitation.",
            },
        )
    return cleaned


def _require_host_phone(raw: object) -> str:
    """Strict normalisation, not the lenient `try_normalize_phone` the CSV importer uses.

    One bad row in a 300-row import must not abort the batch; one bad number in one form is
    a mistake the admin should be told about, and silently storing None would violate the
    NOT NULL column and surface as a 500 instead of a field error (design D4).
    """
    try:
        normalized = phone.normalize_phone(raw if isinstance(raw, str) else None)
    except phone.InvalidPhoneNumberError as exc:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail={
                "code": "invalid_host_phone",
                "field": "host_phone",
                "message": str(exc),
            },
        ) from exc

    if normalized is None:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail={
                "code": "host_phone_required",
                "field": "host_phone",
                "message": "A host phone is required — it is the number guests call.",
            },
        )
    return normalized


def _clean_messages(messages: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
    """Apply the same rules the wedding-wide endpoint applies (design D9).

    Validated here rather than in the Pydantic model so the 422 carries the message the host
    needs — how long it actually is, and why the limit exists.
    """
    try:
        return greeting.validate_messages(messages)
    except greeting.MessageTooLongError as exc:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
        ) from exc


@router.get("/admin/events", response_model=list[AdminEventRead])
async def list_events(
    admin: CurrentAdmin = Depends(require(Action.VIEW_DASHBOARD)),
    session: AsyncSession = Depends(get_session),
) -> list[Event]:
    # Scope in the select, not a filter over the result (design D2): a Host must never see
    # a count, a total or a gap that implies the events they cannot reach.
    rows = await session.scalars(
        select(Event).where(scope.visible_events(admin)).order_by(Event.starts_at)
    )
    return list(rows)


class EventCreate(BaseModel):
    """What an admin fills in to bring an event into existence (design D12).

    `slug` is absent on purpose: it is derived from the name and allocated by the server, so
    a caller cannot mint a public address by hand or collide with an existing one.
    """

    type: EventType
    title_en: str = Field(min_length=1, max_length=200)
    title_bn: str = Field(min_length=1, max_length=200)
    starts_at: datetime
    venue_name: str = Field(min_length=1)
    venue_address: str = Field(min_length=1)
    #: Who is holding the event — a parent of the couple, one or two of them. The second is
    #: optional; the first and the phone are not, because the invitation renders them for
    #: every guest (design D1).
    host_name_1: str = Field(min_length=1, max_length=200)
    host_phone: str = Field(min_length=1)
    host_name_2: str | None = Field(default=None, max_length=200)
    ends_at: datetime | None = None
    map_url: str | None = None
    dress_code: str | None = None
    notes: str | None = None
    rsvp_deadline: datetime | None = None
    capacity: int | None = None
    theme_key: str = "classic"
    #: Draft by default. An event is not reachable at `/e/{slug}` until it is published, so
    #: creating one cannot accidentally expose a half-filled page.
    is_published: bool = False
    #: Empty means the event follows the wedding-wide greeting, which is the right default
    #: for an event nobody has written custom wording for (design D11).
    invitation_messages: dict[str, dict[str, str]] = Field(default_factory=dict)
    #: Super Admin only, and optional even then — creating an event on a host's behalf. A
    #: Host sending this is refused rather than silently ignored, because silently ignoring
    #: it would tell them the event went to the person they named when it did not.
    owner_admin_id: uuid.UUID | None = None


@router.post("/admin/events", response_model=AdminEventRead, status_code=status.HTTP_201_CREATED)
async def create_event(
    payload: EventCreate,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.EDIT_CONTENT)),
    session: AsyncSession = Depends(get_session),
) -> Event:
    """Create an event of one of the three ceremony types.

    There is deliberately no uniqueness on (wedding, type): a family may hold two receptions,
    and the constraint that used to forbid that encoded "one wedding, exactly three
    ceremonies" into the schema (design D12).
    """
    wedding = await session.scalar(select(Wedding).limit(1))
    if wedding is None:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="No wedding exists yet. Run the seed before creating events.",
        )

    owner_id = await _resolve_owner(session, admin, payload.owner_admin_id)

    fields = payload.model_dump(exclude={"type", "owner_admin_id"})
    # Cleaned rather than trusted: the create path stores the same column the PATCH does, so
    # it has to enforce the same cap and the same markup stripping.
    fields["invitation_messages"] = _clean_messages(payload.invitation_messages)
    _clean_host_fields(fields)

    event = Event(
        wedding_id=wedding.id,
        owner_admin_id=owner_id,
        type=payload.type,
        slug=await slugs.allocate(session, payload.title_en, fallback=payload.type.value),
        **fields,
    )
    session.add(event)
    await session.flush()

    audit.record(
        session,
        action=audit.Actions.EVENT_CREATE,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="event",
        entity_id=event.id,
        after={
            "type": event.type.value,
            "slug": event.slug,
            "title_en": event.title_en,
            "owner_admin_id": str(event.owner_admin_id),
        },
        ip=_client_ip(request),
    )
    return event


async def _resolve_owner(
    session: AsyncSession, admin: CurrentAdmin, requested: uuid.UUID | None
) -> uuid.UUID:
    """Who owns the event being created (spec event-management).

    A Host owns what they create, full stop — accepting a requested owner from them would let
    them push an event onto somebody else's list, or, worse, read back through it.
    """
    if requested is None or requested == admin.id:
        return admin.id

    if not scope.is_unscoped(admin):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Only a Super Admin can create an event owned by someone else.",
        )

    owner = await session.get(AdminUser, requested)
    if owner is None or owner.status is not AdminStatus.ACTIVE:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="An event can only be owned by an active account.",
        )
    return owner.id


class EventDeleteImpact(BaseModel):
    """What deleting an event destroys, so the confirmation can state it (spec)."""

    guests: int
    responses: int


@router.get("/admin/events/{event_id}/delete-impact", response_model=EventDeleteImpact)
async def event_delete_impact(
    event_id: uuid.UUID,
    admin: CurrentAdmin = Depends(require(Action.EDIT_CONTENT)),
    session: AsyncSession = Depends(get_session),
) -> EventDeleteImpact:
    """Counts for the confirmation dialog. Read-only and safe to call repeatedly."""
    # Scoped: answering with counts for an event the caller cannot reach would confirm it
    # exists and leak how large somebody else's guest list is.
    await scope.require_event_access(session, admin, event_id)

    guests = await session.scalar(
        select(func.count(Guest.id)).where(Guest.event_id == event_id, Guest.is_deleted.is_(False))
    )
    responses = await session.scalar(
        select(func.count(Rsvp.id))
        .join(Invitation, Invitation.id == Rsvp.invitation_id)
        .where(Invitation.event_id == event_id)
    )
    return EventDeleteImpact(guests=guests or 0, responses=responses or 0)


@router.delete("/admin/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event(
    event_id: uuid.UUID,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.EDIT_CONTENT)),
    session: AsyncSession = Depends(get_session),
) -> None:
    """Delete an event and everything under it.

    Guests belong to one event (design D11), so this cascades to their records, invitations
    and RSVPs. The confirmation the interface shows is built from `delete-impact` above; the
    counts are captured into the audit log here because after the cascade nothing can be
    counted retrospectively.
    """
    event = await scope.scoped_event(session, admin, event_id)

    guests = await session.scalar(select(func.count(Guest.id)).where(Guest.event_id == event_id))
    responses = await session.scalar(
        select(func.count(Rsvp.id))
        .join(Invitation, Invitation.id == Rsvp.invitation_id)
        .where(Invitation.event_id == event_id)
    )

    audit.record(
        session,
        action=audit.Actions.EVENT_DELETE,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="event",
        entity_id=event_id,
        before={
            "slug": event.slug,
            "title_en": event.title_en,
            "guests": guests or 0,
            "responses": responses or 0,
        },
        ip=_client_ip(request),
    )
    await session.delete(event)


class EventUpdate(BaseModel):
    """Every field optional — a PATCH that only moves the date must not blank the venue.

    `slug` is not updatable: it is the address on already-printed QR cards (design D12).

    `owner_admin_id` is absent for the same kind of reason, and it is load-bearing: editing an
    event must never reassign it, and a Host must never reassign anything. Leaving the field
    off the model means no request body can express it — a check that cannot be forgotten,
    unlike one written in the handler. Transfer lives on its own Super-Admin-only endpoint.
    """

    title_en: str | None = None
    title_bn: str | None = None
    starts_at: datetime | None = None
    ends_at: datetime | None = None
    venue_name: str | None = None
    venue_address: str | None = None
    map_url: str | None = None
    dress_code: str | None = None
    notes: str | None = None
    #: Omit to leave the host alone. Sent explicitly, the two required ones are validated the
    #: same way creation validates them — an edit may not empty what creation demanded.
    host_name_1: str | None = None
    host_name_2: str | None = None
    host_phone: str | None = None
    rsvp_deadline: datetime | None = None
    capacity: int | None = None
    is_published: bool | None = None
    theme_key: str | None = None
    #: Sent as the whole map, so emptying it is expressible — with a partial update, "not
    #: sent" and "cleared back to inheriting" would be the same request.
    invitation_messages: dict[str, dict[str, str]] | None = None


class EventUpdateResult(BaseModel):
    event: AdminEventRead
    date_changed: bool
    #: Reminder jobs moved to the new date.
    reminders_rescheduled: int = 0
    #: Reminder jobs dropped because the new date puts their wave in the past.
    reminders_cancelled: int = 0
    #: Newly created jobs for waves that did not exist before the move.
    reminders_created: int = 0
    #: True when guests have already been told the old date; the UI offers a broadcast.
    broadcast_recommended: bool = False


@router.patch("/admin/events/{event_id}", response_model=EventUpdateResult)
async def update_event(
    event_id: uuid.UUID,
    payload: EventUpdate,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.EDIT_CONTENT)),
    session: AsyncSession = Depends(get_session),
) -> EventUpdateResult:
    event = await session.scalar(
        select(Event)
        .where(Event.id == event_id, scope.visible_events(admin))
        .options(selectinload(Event.wedding))
    )
    if event is None:
        raise scope.not_found()

    changes = payload.model_dump(exclude_unset=True)
    if "invitation_messages" in changes:
        changes["invitation_messages"] = _clean_messages(changes["invitation_messages"] or {})
    _clean_host_fields(changes)
    before = {k: getattr(event, k) for k in changes}
    old_starts_at = event.starts_at

    for field, value in changes.items():
        setattr(event, field, value)

    date_changed = "starts_at" in changes and event.starts_at != old_starts_at
    result = EventUpdateResult(
        event=AdminEventRead.model_validate(event, from_attributes=True),
        date_changed=date_changed,
    )

    if date_changed:
        # Flush first: replan reads `event.starts_at` back through the session's queries.
        await session.flush()
        replan = await planner.replan_event(session, event)
        result.reminders_rescheduled = replan.rescheduled
        result.reminders_cancelled = replan.cancelled_past
        result.reminders_created = sum(p.created for p in replan.plans)
        result.broadcast_recommended = replan.broadcast_recommended

    audit.record(
        session,
        action=audit.Actions.EVENT_UPDATE,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="event",
        entity_id=event.id,
        before={k: _json(v) for k, v in before.items()},
        after={k: _json(v) for k, v in changes.items()},
        ip=_client_ip(request),
    )
    return result


def _json(value: object) -> object:
    return value.isoformat() if isinstance(value, datetime) else value


class DateChangeBroadcast(BaseModel):
    """Explicit second step. Re-planning is automatic because leaving stale jobs queued is
    never right; telling 800 people is a decision, so it stays a separate button."""

    audience_accepted_only: bool = True


class BroadcastResult(BaseModel):
    queued: int
    skipped_no_email: int


@router.post("/admin/events/{event_id}/broadcast-date-change", response_model=BroadcastResult)
async def broadcast_date_change(
    event_id: uuid.UUID,
    payload: DateChangeBroadcast,
    request: Request,
    admin: CurrentAdmin = Depends(require(Action.SEND_MESSAGES)),
    session: AsyncSession = Depends(get_session),
) -> BroadcastResult:
    from app.models import Guest, Invitation
    from app.models.enums import InvitationStatus
    from app.services import messaging

    await scope.require_event_access(session, admin, event_id)

    stmt = (
        select(Invitation, Guest)
        .join(Guest, Guest.id == Invitation.guest_id)
        .where(Invitation.event_id == event_id, Guest.is_deleted.is_(False))
    )
    if payload.audience_accepted_only:
        stmt = stmt.where(Invitation.status == InvitationStatus.ACCEPTED)

    queued = skipped = 0
    now = datetime.now(tz=UTC)
    for invitation, guest in (await session.execute(stmt)).all():
        if not guest.email or guest.email_invalid or guest.do_not_contact:
            skipped += 1
            continue
        if await messaging.enqueue(
            session,
            invitation_id=invitation.id,
            template_id=None,
            schedule_id=None,
            scheduled_for=now,
        ):
            queued += 1

    audit.record(
        session,
        action=audit.Actions.MESSAGE_SEND,
        admin_user_id=admin.id,
        actor_email=admin.email,
        entity_type="event",
        entity_id=event_id,
        after={"kind": "date_change_broadcast", "queued": queued, "skipped_no_email": skipped},
        ip=_client_ip(request),
    )
    return BroadcastResult(queued=queued, skipped_no_email=skipped)
