"""Which records may this caller touch (spec event-management, design D2, D3).

`policy.can()` answers *what kind of thing* a role may do. This answers *to which records*,
and a Host is refused by either one independently.

**The rule is that scope goes in the WHERE clause.** Loading a row and then comparing
`event.owner_admin_id` works for a single fetch and fails everywhere else: an aggregate would
count rows the caller may not see before discarding them, an export would stream them, and
every endpoint returning a list would need the comparison repeated per row — which is exactly
the kind of check that is present in eleven places and missing in the twelfth. Selecting under
scope makes the leak structurally impossible instead of conditionally absent.

A Super Admin's fragment is `true_()`, so both roles run the same code path and there is no
`if role is SUPER_ADMIN` branch scattered through 42 endpoints to get one of wrong.
"""

import uuid
from typing import TYPE_CHECKING

from fastapi import HTTPException, status
from sqlalchemy import ColumnElement, Select, true
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import select

from app.models.enums import AdminRole
from app.models.guest import Guest, Invitation
from app.models.wedding import Event

if TYPE_CHECKING:  # pragma: no cover - import cycle: auth imports policy, not scope
    from app.services.auth import CurrentAdmin


#: Raised for anything the caller may not reach. **404, not 403** — a 403 on a real id and a
#: 404 on a fabricated one is a difference an attacker can measure, and measuring it across a
#: range of ids tells a host how many other customers exist and, from the events, when their
#: weddings are. Capability denials stay 403 in `auth.require`: those say nothing about
#: whether any particular record exists.
def not_found() -> HTTPException:
    return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")


def is_unscoped(admin: "CurrentAdmin") -> bool:
    return admin.role is AdminRole.SUPER_ADMIN


def require_system_wide(admin: "CurrentAdmin") -> None:
    """Guard a setting that is not owned by any one event.

    A handful of records — the wedding itself, its reply address, the wedding-wide fallback
    greeting — span every event, so "scope" has no meaning for them and `visible_events`
    cannot express the answer. A Host editing one would silently change what every other
    host's invitations say and where their guests' replies go.

    403, not 404: the record plainly exists, they simply may not write it. Nothing is
    revealed, because a single system-wide wedding is not a secret — its existence is on
    every invitation page.
    """
    if is_unscoped(admin):
        return
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail=(
            "This setting applies to every event, so only a Super Admin can change it. "
            "Edit your own event's details instead."
        ),
    )


def visible_events(admin: "CurrentAdmin") -> ColumnElement[bool]:
    """A WHERE fragment over `Event`. Compose it into the select, do not filter after."""
    if is_unscoped(admin):
        return true()
    return Event.owner_admin_id == admin.id


def visible_event_ids(admin: "CurrentAdmin") -> Select[tuple[uuid.UUID]]:
    """The same set as a subquery, for `IN (...)` against a table that has an `event_id`."""
    return select(Event.id).where(visible_events(admin))


def guest_in_scope(admin: "CurrentAdmin") -> ColumnElement[bool]:
    """A guest is reachable exactly when its event is (spec guest-management).

    Derived, never stored. A guest carrying its own owner column could disagree with the
    event's, and then a transfer has to remember to rewrite every guest row to stay honest.
    """
    if is_unscoped(admin):
        return true()
    return Guest.event_id.in_(visible_event_ids(admin))


def invitation_in_scope(admin: "CurrentAdmin") -> ColumnElement[bool]:
    if is_unscoped(admin):
        return true()
    return Invitation.event_id.in_(visible_event_ids(admin))


async def scoped_event(session: AsyncSession, admin: "CurrentAdmin", event_id: uuid.UUID) -> Event:
    """Load one event the caller may reach, or raise the same 404 a fake id would."""
    event = await session.scalar(select(Event).where(Event.id == event_id, visible_events(admin)))
    if event is None:
        raise not_found()
    return event


async def require_event_access(
    session: AsyncSession, admin: "CurrentAdmin", event_id: uuid.UUID
) -> None:
    """Assert reachability without loading the row, for endpoints that only need the id."""
    reachable = await session.scalar(
        select(Event.id).where(Event.id == event_id, visible_events(admin))
    )
    if reachable is None:
        raise not_found()


async def scoped_guest(session: AsyncSession, admin: "CurrentAdmin", guest_id: uuid.UUID) -> Guest:
    guest = await session.scalar(select(Guest).where(Guest.id == guest_id, guest_in_scope(admin)))
    if guest is None:
        raise not_found()
    return guest


async def scoped_invitation(
    session: AsyncSession, admin: "CurrentAdmin", invitation_id: uuid.UUID
) -> Invitation:
    invitation = await session.scalar(
        select(Invitation).where(Invitation.id == invitation_id, invitation_in_scope(admin))
    )
    if invitation is None:
        raise not_found()
    return invitation
