"""Admin sign-in routes (task 3.1, spec admin-auth).

Three ways in, and they are not equals:

* **Google** — the browser posts an ID token, FastAPI verifies it against Google's keys. An
  unknown verified address does not bounce: it becomes a pending account waiting in the
  roster's approval queue (design D6).
* **Password** — a username and password against an account a super admin created. This is
  the only credential here that can be guessed, so it carries a per-account lockout and a
  per-IP limit and answers every failure identically (design D15).
* **Dev bypass** — an email and nothing else, refused unless the build is a development one
  with the flag set. It never provisions and it never lifts the password confinement.

Plus the two endpoints a confined session may still reach: reading its own identity, and
changing its own password.
"""

import logging
import uuid
from datetime import UTC, datetime, timedelta

from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, Field
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 import audit, auth, passwords, rate_limit
from app.services.policy import actions_for

logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"])


class GoogleSignInRequest(BaseModel):
    id_token: str


class PasswordSignInRequest(BaseModel):
    username: str = Field(min_length=1, max_length=64)
    password: str = Field(min_length=1, max_length=256)


class DevSignInRequest(BaseModel):
    """Dev bypass only — rejected unless AUTH_DEV_BYPASS is on and ENVIRONMENT is dev."""

    email: str


class PasswordChangeRequest(BaseModel):
    current_password: str = Field(min_length=1, max_length=256)
    new_password: str = Field(min_length=1, max_length=256)


class SessionRead(BaseModel):
    """The signed-in admin's own account. Also serves the profile screen (task 7.1).

    One payload rather than two, because a second endpoint returning the same five fields
    about the same person is a second thing to keep in step.
    """

    email: str
    name: str | None
    role: AdminRole
    permissions: list[str]
    #: Null on a Google account, which signs in with no username.
    username: str | None
    auth_method: AuthMethod
    #: Drives the forced password-change screen. The API enforces the confinement itself;
    #: this only saves the frontend from discovering it by being refused.
    must_change_password: bool


#: Distinct from `GENERIC_SIGNIN_ERROR`. Once Google has verified who is asking, telling them
#: their account is waiting for approval hides nothing that self-registration has not already
#: revealed, and staying generic would strand every legitimate new host at a dead end that
#: reads as a bug (design D7).
PENDING_MESSAGE = "Your access is awaiting approval by an administrator."
WITHDRAWN_MESSAGE = "Your access has been withdrawn."


def _client_ip(request: Request) -> str | None:
    forwarded = request.headers.get("x-forwarded-for")
    if forwarded:
        return forwarded.split(",")[0].strip()
    return request.client.host if request.client else None


def _session_payload(admin: auth.CurrentAdmin) -> SessionRead:
    return SessionRead(
        email=admin.email,
        name=admin.name,
        role=admin.role,
        # Drives role-aware navigation. The frontend uses this to hide controls; the API
        # still enforces every one of them independently.
        permissions=sorted(str(a) for a in actions_for(admin.role)),
        username=admin.username,
        auth_method=admin.auth_method,
        must_change_password=admin.must_change_password,
    )


def _state_error(admin: AdminUser) -> HTTPException:
    """The specific refusal for an account that exists but cannot sign in."""
    message = PENDING_MESSAGE if admin.status is AdminStatus.PENDING else WITHDRAWN_MESSAGE
    return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=message)


async def _find_by_email(session: AsyncSession, email: str) -> AdminUser | None:
    admin: AdminUser | None = await session.scalar(
        select(AdminUser).where(func.lower(AdminUser.email) == email.lower())
    )
    return admin


# ------------------------------------------------------------------------------- Google


@router.post("/auth/google", response_model=SessionRead)
async def sign_in_with_google(
    payload: GoogleSignInRequest,
    request: Request,
    response: Response,
    session: AsyncSession = Depends(get_session),
) -> SessionRead:
    claims = await auth.verify_google_id_token(payload.id_token)
    email = str(claims.get("email", "")).lower()
    ip = _client_ip(request)

    admin = await _find_by_email(session, email)

    if admin is None:
        await _provision_pending(session, email=email, name=claims.get("name"), ip=ip)
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=PENDING_MESSAGE)

    if admin.auth_method is not AuthMethod.GOOGLE:
        # A password account's address arriving as a Google assertion (design D16). Generic,
        # because confirming the address belongs to a password account would tell an
        # attacker exactly which form to attack.
        audit.record(session, action=audit.Actions.SIGNIN_REJECTED, actor_email=email, ip=ip)
        await session.commit()
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail=auth.GENERIC_SIGNIN_ERROR
        )

    if admin.status is not AdminStatus.ACTIVE:
        audit.record(
            session,
            action=audit.Actions.SIGNIN_REJECTED,
            actor_email=email,
            ip=ip,
            after={"state": str(admin.status)},
        )
        await session.commit()
        raise _state_error(admin)

    admin.last_login_at = datetime.now(UTC)
    if not admin.name and claims.get("name"):
        admin.name = str(claims["name"])

    auth.issue_session(response, admin)
    audit.record(
        session,
        action=audit.Actions.SIGNIN_SUCCESS,
        admin_user_id=admin.id,
        actor_email=admin.email,
        ip=ip,
        after={"method": "google"},
    )
    return _session_payload(auth.current_admin_from(admin))


async def _provision_pending(
    session: AsyncSession, *, email: str, name: object, ip: str | None
) -> None:
    """Create the inactive record a first Google sign-in produces (design D6).

    Committed here, before the caller raises. `audit.record` deliberately leaves rows in the
    caller's transaction so an action can never be logged as having happened when it was
    rolled back — right everywhere else, wrong here: the thing being recorded *is* the
    refusal, and the response that follows rolls the session back. Without this commit the
    account and its trail both vanish and the visitor is told to wait for an approval of
    something that was never written down.
    """
    settings = get_settings()
    allowed = await rate_limit.check_and_increment(
        session,
        ip,
        bucket=rate_limit.ADMIN_SIGNUP_BUCKET,
        limit=settings.admin_signup_ip_limit,
        window=timedelta(seconds=settings.admin_signup_ip_window_seconds),
    )
    if not allowed:
        await session.commit()
        logger.warning("admin self-registration rate limit reached")
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="Too many attempts. Try again later.",
        )

    now = datetime.now(UTC)
    session.add(
        AdminUser(
            email=email,
            name=str(name) if name else None,
            role=AdminRole.HOST,
            status=AdminStatus.PENDING,
            auth_method=AuthMethod.GOOGLE,
            first_seen_at=now,
        )
    )
    audit.record(
        session,
        action=audit.Actions.ADMIN_SELF_REGISTERED,
        actor_email=email,
        ip=ip,
    )
    await session.commit()
    logger.info("created a pending admin account from a first Google sign-in")


# ----------------------------------------------------------------------------- password


@router.post("/auth/password", response_model=SessionRead)
async def sign_in_with_password(
    payload: PasswordSignInRequest,
    request: Request,
    response: Response,
    session: AsyncSession = Depends(get_session),
) -> SessionRead:
    """Username and password against an account a super admin created (design D15, D16).

    Every failure below answers with the same 401 and the same message. An unknown username,
    a Google account's address, a wrong password and a locked account are four different
    situations and exactly one response, because the difference between them is what an
    attacker working through a list is trying to measure.
    """
    settings = get_settings()
    ip = _client_ip(request)
    refused = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED, detail=auth.GENERIC_SIGNIN_ERROR
    )

    allowed = await rate_limit.check_and_increment(
        session,
        ip,
        bucket=rate_limit.PASSWORD_SIGNIN_BUCKET,
        limit=settings.password_signin_ip_limit,
        window=timedelta(seconds=settings.password_signin_ip_window_seconds),
    )
    if not allowed:
        await session.commit()
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="Too many attempts. Try again later.",
        )

    admin = await session.scalar(
        select(AdminUser).where(
            func.lower(AdminUser.username) == payload.username.lower(),
            AdminUser.auth_method == AuthMethod.PASSWORD,
        )
    )

    now = datetime.now(UTC)

    if admin is None:
        # Still hash something, so a missing account does not answer measurably faster than
        # a wrong password does.
        passwords.verify(None, payload.password)
        await session.commit()
        raise refused

    if admin.locked_until is not None and admin.locked_until > now:
        await session.commit()
        raise refused

    if not passwords.verify(admin.password_hash, payload.password):
        admin.failed_login_count += 1
        if admin.failed_login_count >= settings.password_max_attempts:
            admin.locked_until = now + timedelta(minutes=settings.password_lockout_minutes)
            admin.failed_login_count = 0
            logger.warning("password account locked after repeated failures")
        audit.record(session, action=audit.Actions.SIGNIN_REJECTED, actor_email=admin.email, ip=ip)
        await session.commit()
        raise refused

    if admin.password_expires_at is not None and admin.password_expires_at <= now:
        # An expired temporary password. Refused generically: it is still a credential that
        # no longer works, and naming the reason would confirm the account exists.
        await session.commit()
        raise refused

    if admin.status is not AdminStatus.ACTIVE:
        # The password was right, so the holder has proven who they are and may be told the
        # state of their own account.
        await session.commit()
        raise _state_error(admin)

    admin.failed_login_count = 0
    admin.locked_until = None
    admin.last_login_at = now
    if admin.password_hash and passwords.needs_rehash(admin.password_hash):
        # Cost parameters were raised since this hash was written; upgrade it now that the
        # plaintext is briefly in hand.
        admin.password_hash = passwords.hash_password(payload.password)

    auth.issue_session(response, admin)
    audit.record(
        session,
        action=audit.Actions.SIGNIN_SUCCESS,
        admin_user_id=admin.id,
        actor_email=admin.email,
        ip=ip,
        after={"method": "password"},
    )
    return _session_payload(auth.current_admin_from(admin))


@router.post("/auth/password/change", response_model=SessionRead)
async def change_own_password(
    payload: PasswordChangeRequest,
    request: Request,
    response: Response,
    # Deliberately `get_current_admin`, not `get_unconfined_admin`: this is one of the three
    # routes a confined session must still reach, since it is the way out of confinement.
    admin: auth.CurrentAdmin = Depends(auth.get_current_admin),
    session: AsyncSession = Depends(get_session),
) -> SessionRead:
    """Change your own password. Never anyone else's — that is the roster's job, and it
    produces a temporary password rather than a chosen one (spec admin-auth)."""
    settings = get_settings()
    row = await session.get(AdminUser, admin.id)
    if row is None:  # pragma: no cover - the dependency just read it
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authorized")

    if row.auth_method is not AuthMethod.PASSWORD:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="This account signs in with Google and has no password to change.",
        )

    if not passwords.verify(row.password_hash, payload.current_password):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect."
        )

    if payload.new_password == payload.current_password:
        # Catches the specific case the spec names — being handed a temporary password and
        # "changing" it to itself, which would leave a credential two people know in place.
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="The new password must be different from the current one.",
        )

    try:
        validated = passwords.validate_quality(
            payload.new_password, min_length=settings.password_min_length
        )
    except passwords.PasswordRejectedError as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

    now = datetime.now(UTC)
    row.password_hash = passwords.hash_password(validated)
    row.password_set_at = now
    row.password_expires_at = None
    row.must_change_password = False
    row.failed_login_count = 0
    row.locked_until = None
    # Drops every other device (design D17), then re-issues for this one — so the admin is
    # not signed out of the browser they just used to do the right thing. Order matters: the
    # new token has to be minted after the bump so it carries the new epoch.
    auth.revoke_sessions(row)
    auth.issue_session(response, row)

    audit.record(
        session,
        action=audit.Actions.ADMIN_PASSWORD_CHANGE,
        admin_user_id=row.id,
        actor_email=row.email,
        entity_type="admin_user",
        entity_id=row.id,
        ip=_client_ip(request),
    )
    return _session_payload(auth.current_admin_from(row))


# -------------------------------------------------------------------------- dev bypass


@router.post("/auth/dev", response_model=SessionRead)
async def sign_in_dev(
    payload: DevSignInRequest,
    request: Request,
    response: Response,
    session: AsyncSession = Depends(get_session),
) -> SessionRead:
    """Sign in without Google and without a password, for local development only.

    Two independent conditions must both hold — the flag and a non-production
    environment — and `assert_production_ready` refuses to boot prod with the flag set. The
    email must still be on the roster and active, so this weakens authentication, not
    authorization. It provisions nothing, and it does not lift the temporary-password
    confinement: `get_unconfined_admin` reads that from the row, not from how you arrived.
    """
    settings = get_settings()
    if settings.is_prod or not settings.auth_dev_bypass:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")

    admin = await auth.lookup_active_admin(session, payload.email)
    if admin is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail=auth.GENERIC_SIGNIN_ERROR
        )

    admin.last_login_at = datetime.now(UTC)
    auth.issue_session(response, admin)
    audit.record(
        session,
        action=audit.Actions.SIGNIN_SUCCESS,
        admin_user_id=admin.id,
        actor_email=admin.email,
        ip=_client_ip(request),
        after={"method": "dev_bypass"},
    )
    logger.warning("DEV BYPASS sign-in used for %s", admin.email)
    return _session_payload(auth.current_admin_from(admin))


# --------------------------------------------------------------------------- session


@router.get("/auth/me", response_model=SessionRead)
async def read_current_session(
    # Reachable while confined, so the frontend can learn *that* it is confined.
    admin: auth.CurrentAdmin = Depends(auth.get_current_admin),
) -> SessionRead:
    return _session_payload(admin)


@router.post("/auth/signout", status_code=status.HTTP_204_NO_CONTENT)
async def sign_out(
    request: Request,
    response: Response,
    session: AsyncSession = Depends(get_session),
) -> None:
    claims = auth.decode_session(request)
    auth.clear_session(response)
    if not claims:
        return
    try:
        admin_id = uuid.UUID(str(claims.get("sub")))
    except (TypeError, ValueError):
        return
    admin = await session.get(AdminUser, admin_id)
    if admin is not None:
        audit.record(
            session,
            action=audit.Actions.SIGNOUT,
            admin_user_id=admin.id,
            actor_email=admin.email,
            ip=_client_ip(request),
        )
