"""Dashboard counts (task 3.5, spec admin-dashboard FR-4.2, FR-4.3).

Every number is computed in SQL rather than by loading rows, so the dashboard stays under
its 1.5s budget with 1,500 guests. Headcount sums party_size across invitations that are
*currently* accepted — which is what makes a cancellation move the caterer's number.
"""

from datetime import UTC, datetime

from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import ColumnElement, Integer, case, func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db import get_session
from app.models import Event, Guest, Invitation, Rsvp
from app.models.enums import InvitationStatus
from app.services import scope
from app.services.auth import CurrentAdmin, require
from app.services.policy import Action

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


class EventStats(BaseModel):
    event_id: str
    type: str
    title_en: str
    title_bn: str
    slug: str
    starts_at: datetime
    days_to_event: int
    capacity: int | None

    invited: int
    accepted: int
    declined: int
    pending: int
    cancelled: int
    headcount: int
    #: Percent, 0-100 — already scaled. Consumers must not multiply again.
    response_rate: float
    over_capacity: bool
    no_email_count: int


class AggregateStats(BaseModel):
    #: Guest *records*, not people. A person invited to three ceremonies is three records
    #: (design D11), so this figure cannot be presented as a headcount of individuals — the
    #: field was called `unique_guests` while that was still true and was renamed with the
    #: scoping change rather than left to mislead whoever reads the dashboard next.
    guest_records: int
    total_headcount: int
    #: Percent, 0-100 — already scaled, same as EventStats.response_rate.
    overall_response_rate: float


class DashboardStats(BaseModel):
    events: list[EventStats]
    aggregate: AggregateStats
    generated_at: datetime
    #: True when the caller owns no events at all. The dashboard shows an invitation to
    #: create one rather than a row of zeroes, which would read as "the system is empty"
    #: to a new Host when it is merely empty *for them* (spec admin-dashboard).
    owns_nothing: bool = False


def _count_when(status: InvitationStatus) -> ColumnElement[int]:
    return func.count(case((Invitation.status == status, 1)))


@router.get("/admin/stats", response_model=DashboardStats)
async def read_dashboard_stats(
    admin: CurrentAdmin = Depends(require(Action.VIEW_DASHBOARD)),
    session: AsyncSession = Depends(get_session),
) -> DashboardStats:
    now = datetime.now(UTC)

    rows = (
        await session.execute(
            select(
                Event.id,
                Event.type,
                Event.title_en,
                Event.title_bn,
                Event.slug,
                Event.starts_at,
                Event.capacity,
                func.count(Invitation.id).label("invited"),
                _count_when(InvitationStatus.ACCEPTED).label("accepted"),
                _count_when(InvitationStatus.DECLINED).label("declined"),
                _count_when(InvitationStatus.CANCELLED).label("cancelled"),
                func.count(
                    case(
                        (
                            Invitation.status.in_(
                                [InvitationStatus.PENDING, InvitationStatus.OPENED]
                            ),
                            1,
                        )
                    )
                ).label("pending"),
                func.coalesce(
                    func.sum(
                        case((Invitation.status == InvitationStatus.ACCEPTED, Rsvp.party_size))
                    ),
                    0,
                ).label("headcount"),
                # v1 is email-only, so a guest without an address is unreachable. Surfacing
                # the number is what stops it becoming a silent gap (spec messaging).
                func.count(case(((Guest.id.isnot(None)) & (Guest.email.is_(None)), 1))).label(
                    "no_email"
                ),
            )
            .select_from(Event)
            # Scope goes here, in the WHERE of the aggregate itself. Filtering the finished
            # rows would have counted another owner's guests before discarding them, which
            # is the same read whether or not the number is ever rendered (design D2).
            .where(scope.visible_events(admin))
            .outerjoin(Invitation, Invitation.event_id == Event.id)
            .outerjoin(Rsvp, Rsvp.invitation_id == Invitation.id)
            .outerjoin(Guest, (Guest.id == Invitation.guest_id) & (Guest.is_deleted.is_(False)))
            .group_by(Event.id)
            .order_by(Event.starts_at)
        )
    ).all()

    events: list[EventStats] = []
    for r in rows:
        responded = r.accepted + r.declined
        events.append(
            EventStats(
                event_id=str(r.id),
                type=str(r.type),
                title_en=r.title_en,
                title_bn=r.title_bn,
                slug=r.slug,
                starts_at=r.starts_at,
                days_to_event=max(0, (r.starts_at - now).days),
                capacity=r.capacity,
                invited=r.invited,
                accepted=r.accepted,
                declined=r.declined,
                pending=r.pending,
                cancelled=r.cancelled,
                headcount=int(r.headcount),
                response_rate=round(responded / r.invited * 100, 1) if r.invited else 0.0,
                over_capacity=bool(r.capacity and r.headcount > r.capacity),
                no_email_count=r.no_email,
            )
        )

    # The aggregate row is per-caller too. A Host's totals describe their own events and are
    # labelled as such; presenting a system-wide figure to someone who can only see part of
    # the system would be worse than showing nothing.
    guest_records = await session.scalar(
        select(func.count(Guest.id)).where(Guest.is_deleted.is_(False), scope.guest_in_scope(admin))
    )
    total_headcount = await session.scalar(
        select(func.coalesce(func.sum(Rsvp.party_size), 0))
        .select_from(Invitation)
        .join(Rsvp, Rsvp.invitation_id == Invitation.id)
        .where(
            Invitation.status == InvitationStatus.ACCEPTED,
            scope.invitation_in_scope(admin),
        )
    )
    totals = (
        await session.execute(
            select(
                func.count(Invitation.id),
                func.count(
                    case(
                        (
                            Invitation.status.in_(
                                [InvitationStatus.ACCEPTED, InvitationStatus.DECLINED]
                            ),
                            1,
                        )
                    )
                ),
            )
            .select_from(Invitation)
            .where(scope.invitation_in_scope(admin))
        )
    ).one()
    all_invites, all_responded = totals

    return DashboardStats(
        events=events,
        aggregate=AggregateStats(
            guest_records=int(guest_records or 0),
            total_headcount=int(total_headcount or 0),
            overall_response_rate=(
                round(all_responded / all_invites * 100, 1) if all_invites else 0.0
            ),
        ),
        generated_at=now,
        owns_nothing=not events,
    )


class TrendPoint(BaseModel):
    day: str
    accepts: int


@router.get("/admin/stats/trend", response_model=list[TrendPoint])
async def read_response_trend(
    days: int = 30,
    admin: CurrentAdmin = Depends(require(Action.VIEW_DASHBOARD)),
    session: AsyncSession = Depends(get_session),
) -> list[TrendPoint]:
    """Accepts per day (FR-4.4), for the response trend chart."""
    rows = (
        await session.execute(
            select(
                func.date_trunc("day", Rsvp.responded_at).label("day"),
                func.count(Rsvp.id).cast(Integer).label("accepts"),
            )
            .select_from(Rsvp)
            .join(Invitation, Invitation.id == Rsvp.invitation_id)
            .where(
                Invitation.status == InvitationStatus.ACCEPTED,
                scope.invitation_in_scope(admin),
            )
            .group_by("day")
            .order_by("day")
        )
    ).all()
    return [TrendPoint(day=r.day.date().isoformat(), accepts=r.accepts) for r in rows]
