"""The app's own sign-in: `/api/auth/*` (login, logout, forgot, token-state, welcome, reset,
change-password, heartbeat), all POST bodies, no token in any path.

Sources of truth: this module, `app/services/sessions.py`, `app/services/directory.py`,
`app/services/passwords.py`, `docs/ACCOUNTS.md`, `tests/test_accounts_login.py`. Design:
`.logs/planning/accounts-2026-09.md` §4.3 and §4.4.

Every refusal of a login is the same `401 LOGIN_REFUSED` with no `Set-Cookie`: an unknown
name, an invited account, a disabled one, a locked one and a wrong password all take the
dummy-verify branch or a real verify of the same cost, so the response sequence never tells
a username that exists from one that does not. Only the per-address throttle answers 429.
The per-account lock is silent; the audit row says `locked`. Tokens arrive in JSON bodies
only (the SPA reads them from the link's fragment), so the access log never prints one.
"""

from __future__ import annotations

import logging
import time
from datetime import timedelta

from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field

from app import db as appdb
from app.models import Account, AccountCredential
from app.services import accounts, audit_log, directory, identity, mail, passwords, sessions
from app.services.throttle import Throttle, client_key

log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# DFP's own /api/bw/me and /api/whoami: included before the vendored kit router, so the
# kit's /me (same path) is shadowed by FastAPI's first-registered-wins, never edited.
me_router = APIRouter(tags=["auth"])

# Per address, per worker (one uvicorn worker is a launch assumption: docs/ACCOUNTS.md).
login_throttle = Throttle(per_key=10, per_key_seconds=900, total=60, total_seconds=60)
# Failures against a name with no row, keyed on the folded name, so eight guesses at a name
# that does not exist behave exactly as eight at one that does.
unknown_throttle = Throttle(per_key=8, per_key_seconds=900, total=10**9, total_seconds=60)
forgot_throttle = Throttle(per_key=5, per_key_seconds=900, total=100, total_seconds=60)
redeem_throttle = Throttle(per_key=10, per_key_seconds=900, total=100, total_seconds=60)
FORGOT_TOKENS_PER_DAY = 3
RETRY_AFTER = "300"

_mono = time.monotonic  # tests replace it


class LoginIn(BaseModel):
    username: str = Field(min_length=1, max_length=320)
    password: str = Field(min_length=1, max_length=passwords.MAX_LENGTH)


class IdentifierIn(BaseModel):
    identifier: str = Field(min_length=1, max_length=320)


class TokenIn(BaseModel):
    token: str = Field(min_length=1, max_length=200)


class TokenPasswordIn(TokenIn):
    password: str = Field(min_length=1, max_length=passwords.MAX_LENGTH)


class ChangePasswordIn(BaseModel):
    current_password: str = Field(min_length=1, max_length=passwords.MAX_LENGTH)
    new_password: str = Field(min_length=1, max_length=passwords.MAX_LENGTH)


class OkOut(BaseModel):
    ok: bool = True


class SignedInOut(BaseModel):
    ok: bool = True
    username: str
    must_change_password: bool = False


class TokenStateOut(BaseModel):
    state: str


def _refuse(status: int, code: str, summary: str, headers: dict | None = None) -> HTTPException:
    return HTTPException(status_code=status, detail={"error_code": code, "summary": summary}, headers=headers)


def _locked_out() -> HTTPException:
    return _refuse(429, "LOGIN_LOCKED_OUT", "Too many attempts. Try again in a few minutes.",
                   {"Retry-After": RETRY_AFTER})


def _login_refused() -> HTTPException:
    return _refuse(401, "LOGIN_REFUSED", "Wrong username or password.")


def _require_session(request: Request) -> sessions.SessionRow:
    row = sessions.of_request(request)
    if row is None:
        raise _refuse(401, "NOT_SIGNED_IN", "Sign in to do this.")
    if not row.active:
        raise _refuse(401, "ACCOUNT_DISABLED", "This account is disabled.")
    return row


def _password_rule(exc: passwords.PasswordRuleError) -> HTTPException:
    return _refuse(422, exc.code, str(exc))


@router.post("/login", response_model=SignedInOut)
def login(payload: LoginIn, request: Request, response: Response) -> SignedInOut:
    address = client_key(request)
    if not login_throttle.allow(address, now=_mono()):
        raise _locked_out()
    now = sessions.utcnow()
    with appdb.SessionLocal() as db:
        account = directory.find(db, payload.username)
        if account is None:
            unknown_throttle.allow(payload.username.strip().lower()[:320], now=_mono())
            passwords.dummy_verify(payload.password)
            audit_log.record("login.refused", detail={"reason": "unknown"}, ip=address)
            raise _login_refused()
        if account.status == "disabled":
            passwords.dummy_verify(payload.password)
            audit_log.record("login.refused", entity_type="account", entity_key=account.username,
                             detail={"reason": "disabled"}, account_id=account.id, ip=address)
            raise _login_refused()
        cred = db.get(AccountCredential, account.id)
        if cred is None or not cred.password_hash:
            passwords.dummy_verify(payload.password)
            audit_log.record("login.refused", entity_type="account", entity_key=account.username,
                             detail={"reason": "invited"}, account_id=account.id, ip=address)
            raise _login_refused()
        if directory.is_locked(cred, now=now):
            passwords.dummy_verify(payload.password)
            audit_log.record("login.refused", entity_type="account", entity_key=account.username,
                             detail={"reason": "locked"}, account_id=account.id, ip=address)
            raise _login_refused()
        if not passwords.verify_password(cred.password_hash, payload.password):
            locked = directory.note_failure(db, cred, now=now)
            audit_log.record("login.refused", entity_type="account", entity_key=account.username,
                             detail={"reason": "bad_password"}, account_id=account.id, ip=address)
            if locked:
                audit_log.record("login.locked", entity_type="account", entity_key=account.username,
                                 account_id=account.id, ip=address)
            raise _login_refused()
        if passwords.needs_rehash(cred.password_hash):
            cred.password_hash = passwords.hash_password(payload.password)
        directory.note_success(db, account, cred, now=now)
        account_id, username, must_change = account.id, account.username, cred.must_change_password
    token = sessions.mint(account_id, ip=address, user_agent=request.headers.get("user-agent"), now=now)
    sessions.set_cookie(response, token)
    audit_log.record("login.ok", entity_type="account", entity_key=username, account_id=account_id,
                     session_hash=sessions.hash_token(token), ip=address)
    return SignedInOut(username=username, must_change_password=must_change)


@router.post("/logout", response_model=OkOut)
def logout(request: Request, response: Response) -> OkOut:
    row = sessions.of_request(request)
    if row is not None:
        sessions.revoke(row.token_hash, reason="logout", by_id=row.account_id)
        audit_log.record("logout", entity_type="account", entity_key=row.username)
    sessions.clear_cookie(response)
    return OkOut()


@router.post("/forgot", response_model=OkOut)
def forgot(payload: IdentifierIn, request: Request) -> OkOut:
    """The answer is always the same. A reset mail goes out when the account exists, is not
    disabled, has an address, is under its daily cap, and a mail provider exists."""
    address = client_key(request)
    if not forgot_throttle.allow(address, now=_mono()):
        raise _locked_out()
    now = sessions.utcnow()
    with appdb.SessionLocal() as db:
        account = directory.find(db, payload.identifier)
        if account is None or account.status == "disabled" or not account.email:
            return OkOut()
        if directory.tokens_minted_since(db, account.id, "reset", now - timedelta(days=1)) >= FORGOT_TOKENS_PER_DAY:
            return OkOut()
        username = account.username
    try:
        directory.LocalDirectory().send_reset(username)
    except (mail.MailNotConfigured, directory.AccountLookupError):
        log.info("forgot: no mail sent for an account (provider or address missing)")
    return OkOut()


@router.post("/token-state", response_model=TokenStateOut)
def token_state(payload: TokenIn) -> TokenStateOut:
    """Read-only: `valid | expired_resendable | used | unknown`, so the welcome and reset pages
    can say what happened without trying a password."""
    with appdb.SessionLocal() as db:
        return TokenStateOut(state=directory.token_state(db, payload.token).state)


def _redeem(payload: TokenPasswordIn, request: Request, response: Response, mode: str) -> SignedInOut:
    address = client_key(request)
    if not redeem_throttle.allow(address, now=_mono()):
        raise _locked_out()
    with appdb.SessionLocal() as db:
        try:
            account = directory.redeem(db, payload.token, payload.password, mode=mode)
        except LookupError as exc:
            raise _refuse(400, "TOKEN_" + str(exc).upper(), "This link is not valid any more.") from None
        except passwords.PasswordRuleError as exc:
            raise _password_rule(exc) from None
        account_id, username = account.id, account.username
    token = sessions.mint(account_id, ip=address, user_agent=request.headers.get("user-agent"), amr=mode)
    sessions.set_cookie(response, token)
    audit_log.record("login.ok", entity_type="account", entity_key=username, account_id=account_id,
                     session_hash=sessions.hash_token(token), detail={"via": mode}, ip=address)
    return SignedInOut(username=username)


@router.post("/welcome", response_model=SignedInOut)
def welcome(payload: TokenPasswordIn, request: Request, response: Response) -> SignedInOut:
    """Redeem an invite token: set the first password, sign in."""
    return _redeem(payload, request, response, "invite")


@router.post("/reset", response_model=SignedInOut)
def reset(payload: TokenPasswordIn, request: Request, response: Response) -> SignedInOut:
    """Redeem a reset token: set a new password, every other session out, sign in."""
    return _redeem(payload, request, response, "reset")


@router.post("/change-password", response_model=OkOut)
def change_password(payload: ChangePasswordIn, request: Request) -> OkOut:
    """The signed-in account replaces its own password; the must-change flag clears; every
    other session of the account is revoked. Under View As this is the REAL account's."""
    row = _require_session(request)
    with appdb.SessionLocal() as db:
        account = db.get(Account, row.account_id)
        cred = db.get(AccountCredential, row.account_id)
        stored = cred.password_hash if cred is not None else None
        if account is None or not passwords.verify_password(stored, payload.current_password):
            raise _refuse(401, "LOGIN_REFUSED", "Wrong username or password.")
        try:
            directory.set_password(db, account, payload.new_password, via="change",
                                   by_id=row.account_id, keep_session_hash=row.token_hash)
        except passwords.PasswordRuleError as exc:
            raise _password_rule(exc) from None
    row.must_change_password = False
    return OkOut()


@router.post("/heartbeat", response_model=OkOut)
def heartbeat(request: Request) -> OkOut:
    """Presence is a POST: the SPA sends one every five minutes while the tab is visible."""
    row = _require_session(request)
    sessions.touch(row.token_hash)
    return OkOut()


@me_router.get("/api/bw/me")
def me(request: Request) -> dict:
    """The kit's capability contract plus DFP's `capabilities` block (accounts plan §4.11).
    The one route that never 401s: an anonymous caller gets the anonymous payload."""
    return accounts.me_payload_for(request)


@me_router.get("/api/whoami")
def whoami(request: Request) -> dict:
    """Effective identity beside the real one; `is_owner` tracks the EFFECTIVE user."""
    user = identity.optional_user(request)
    if user is None:
        raise _refuse(401, "NOT_SIGNED_IN", "Sign in to do this.")
    return {"username": user, "real_user": identity.real_user(request), "is_owner": accounts.is_owner(user)}
