"""Admin authentication (design D7, D12, D17, spec admin-auth).

FastAPI owns the whole flow: it verifies the credential — a Google ID token or a password —
checks the account, and issues its own signed session. Next.js never mints or validates one;
if it did, the trust boundary the split architecture exists to create would be decorative.

**The session is a JWT, and it carries identity only.** Role, status and scope are re-read
from the database on every single request. That is what makes the two-day lifetime safe: a
demotion, a withdrawal or an ownership transfer applies to the very next request instead of
whenever the token happens to expire. Putting the role in a claim would trade that for one
saved query and is the mistake this docstring exists to prevent.

**The JWT lives in the same httpOnly cookie the signed session used to.** "JWT-based" is a
statement about the token's format, not about handing it to page JavaScript. In
`localStorage` it would be readable by any XSS on an admin screen and valid for two days from
any machine — strictly worse than the 12-hour cookie it replaced (design D12).
"""

import logging
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any

import jwt
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.config import get_settings
from app.db import get_session
from app.models import AdminUser
from app.models.enums import AdminRole, AdminStatus, AuthMethod
from app.services.policy import Action, can

logger = logging.getLogger(__name__)

SESSION_COOKIE = "rsvp_admin_session"

# One generic message for every credential failure. Telling an attacker whether an address
# is known would turn the sign-in form into a membership oracle — and on the password path
# the difference between "no such username" and "wrong password" is worth real money to
# someone guessing. Account *state* is reported specifically, but only after Google has
# verified who is asking (design D7).
GENERIC_SIGNIN_ERROR = "Not authorized"

#: Returned to a caller confined by a temporary password, so the frontend can route to the
#: change-password screen instead of guessing at a bare 403 (design D14).
PASSWORD_CHANGE_REQUIRED = "password_change_required"


@dataclass(frozen=True)
class CurrentAdmin:
    """The authenticated admin for this request, with a freshly read role and status."""

    #: Concretely a UUID, not `Any`. It is compared against `event.owner_admin_id` in every
    #: scope fragment, and an untyped id turns each of those comparisons into `Any` — which
    #: is exactly the check mypy should be watching hardest.
    id: uuid.UUID
    email: str
    name: str | None
    role: AdminRole
    username: str | None = None
    auth_method: AuthMethod = AuthMethod.GOOGLE
    must_change_password: bool = False

    def can(self, action: Action) -> bool:
        return can(self.role, action)


def current_admin_from(admin: AdminUser) -> CurrentAdmin:
    return CurrentAdmin(
        id=admin.id,
        email=admin.email,
        name=admin.name,
        role=admin.role,
        username=admin.username,
        auth_method=admin.auth_method,
        must_change_password=admin.must_change_password,
    )


# --------------------------------------------------------------------------- tokens


def issue_session(response: Response, admin: AdminUser) -> str:
    """Set the session cookie to a fresh JWT. Only the account id is stored — never role."""
    settings = get_settings()
    now = datetime.now(UTC)
    payload = {
        "sub": str(admin.id),
        "iat": int(now.timestamp()),
        "exp": int(now.timestamp()) + settings.session_max_age_seconds,
        # A per-token id, so an individual session is identifiable in the audit trail
        # without the token itself ever being written there.
        "jti": uuid.uuid4().hex,
        # The account's session epoch at issue time. A token carrying an older one is
        # refused, which is how a password change drops sessions held elsewhere (D17).
        "sv": admin.session_epoch or 0,
    }
    token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
    response.set_cookie(
        SESSION_COOKIE,
        token,
        max_age=settings.session_max_age_seconds,
        httponly=True,
        secure=settings.is_prod,  # plain HTTP on localhost in dev
        samesite="lax",
        path="/",
    )
    return token


def clear_session(response: Response) -> None:
    response.delete_cookie(SESSION_COOKIE, path="/")


def revoke_sessions(admin: AdminUser) -> None:
    """Drop every outstanding token for this account (design D17).

    Call it *before* `issue_session` when the caller should keep working: the new token picks
    up the bumped epoch and every older one is left behind.

    A counter, not a "sessions valid from" timestamp. A JWT's `iat` is a whole number of
    seconds, so any timestamp comparison lands on one of two bugs — it signs out the token
    issued alongside the change, which is the admin who just set their own password and whom
    the spec says must stay signed in; or, truncated to dodge that, it leaves a one-second
    window in which a stolen token outlives the revocation meant to kill it. Equality on an
    integer has neither, and no clock semantics to reason about.
    """
    admin.session_epoch = (admin.session_epoch or 0) + 1


def decode_session(request: Request) -> dict[str, Any] | None:
    """Return the claims of a valid, unexpired session token, or None."""
    raw = request.cookies.get(SESSION_COOKIE)
    if not raw:
        return None
    settings = get_settings()
    try:
        claims: dict[str, Any] = jwt.decode(
            raw,
            settings.jwt_secret,
            algorithms=[settings.jwt_algorithm],
            options={"require": ["exp", "iat", "sub", "sv"]},
        )
    except jwt.ExpiredSignatureError:
        logger.info("admin session expired")
        return None
    except jwt.InvalidTokenError:
        # Covers a bad signature, a malformed token, and one signed with another algorithm.
        logger.warning("admin session token failed verification")
        return None
    return claims


async def lookup_active_admin(session: AsyncSession, email: str) -> AdminUser | None:
    """An active account for this email, or None. Pending and withdrawn both return None."""
    admin: AdminUser | None = await session.scalar(
        select(AdminUser).where(
            func.lower(AdminUser.email) == email.lower(),
            AdminUser.status == AdminStatus.ACTIVE,
        )
    )
    return admin


async def verify_google_id_token(id_token_str: str) -> dict[str, Any]:
    """Verify signature, audience and issuer against Google's public keys.

    Raises HTTPException(401) with the generic message on any failure — a malformed token
    and a token for someone else's app must be indistinguishable from outside.
    """
    settings = get_settings()
    try:
        from google.auth.transport import requests as google_requests
        from google.oauth2 import id_token as google_id_token

        # google-auth ships no type information, so this call is untyped to mypy. The
        # result is validated below rather than trusted.
        claims: dict[str, Any] = google_id_token.verify_oauth2_token(  # type: ignore[no-untyped-call]
            id_token_str,
            google_requests.Request(),
            settings.google_client_id,
        )
    except Exception as exc:
        logger.warning("google id token verification failed: %s", type(exc).__name__)
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail=GENERIC_SIGNIN_ERROR
        ) from exc

    if not claims.get("email_verified"):
        # An unverified address proves nothing about who is signing in.
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=GENERIC_SIGNIN_ERROR)
    return claims


# ----------------------------------------------------------------------- dependencies


async def get_current_admin(
    request: Request,
    session: AsyncSession = Depends(get_session),
) -> CurrentAdmin:
    """Dependency for every admin route. 401 when absent, expired, revoked or deactivated."""
    unauthenticated = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
    )

    claims = decode_session(request)
    if claims is None:
        raise unauthenticated

    try:
        admin_id = uuid.UUID(str(claims.get("sub")))
    except (TypeError, ValueError):
        logger.warning("admin session subject is not a uuid")
        raise unauthenticated from None

    admin = await session.get(AdminUser, admin_id)
    # Deactivated mid-session, put back into the pending queue, or deleted outright. The
    # row is the authority, so all three take effect on this request rather than at expiry.
    if admin is None or admin.status is not AdminStatus.ACTIVE:
        raise unauthenticated

    # The one revocation the row lookup cannot express: a still-valid token for a
    # still-active account whose password has since changed (design D17).
    if claims.get("sv") != (admin.session_epoch or 0):
        logger.info("admin session predates a credential change; refusing")
        raise unauthenticated

    return current_admin_from(admin)


async def get_unconfined_admin(
    admin: CurrentAdmin = Depends(get_current_admin),
) -> CurrentAdmin:
    """`get_current_admin`, plus the temporary-password confinement (design D14).

    Applied to **every** admin route. The three routes that must stay reachable while
    confined — reading your own identity, changing your own password, signing out — depend on
    `get_current_admin` directly instead.

    It is opt-out rather than opt-in on purpose: a new endpoint added later is confined
    unless somebody deliberately says otherwise, which is the safe direction to be wrong in.
    """
    if admin.must_change_password:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=PASSWORD_CHANGE_REQUIRED,
        )
    return admin


def require(action: Action) -> Callable[..., Awaitable[CurrentAdmin]]:
    """Guard a route with a capability.

        @router.delete("/guests/{id}", dependencies=[Depends(require(Action.DELETE_GUESTS))])

    Returns 403 rather than 404: the caller is authenticated, just not permitted, and the
    answer says nothing about whether any particular record exists. Scope failures are the
    opposite case and return 404 — see `services/scope.py`.

    This also carries the confinement check, so guarding a route by capability cannot
    accidentally opt it out of the temporary-password gate.
    """

    async def _dependency(admin: CurrentAdmin = Depends(get_unconfined_admin)) -> CurrentAdmin:
        if not admin.can(action):
            logger.info("denied %s to %s (%s)", action, admin.email, admin.role)
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Your role does not permit: {action}",
            )
        return admin

    return _dependency
