"""Authentication API routes."""

import asyncio

from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession

import bcrypt

from ..database.engine import get_session
from ..database.models import User
from .dependencies import get_current_user
from .jwt import create_access_token, create_refresh_token, decode_token

router = APIRouter(prefix="/api/auth", tags=["auth"])

# Default rate limit applied by SlowAPIMiddleware to every route that does NOT
# specify its own @limiter.limit(...) — protects against blanket abuse.
limiter = Limiter(key_func=get_remote_address, default_limits=["600/minute"])


class SetupRequest(BaseModel):
    username: str
    password: str


class LoginRequest(BaseModel):
    username: str
    password: str


class TokenResponse(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"


class UserResponse(BaseModel):
    id: int
    username: str
    role: str


class RefreshRequest(BaseModel):
    refresh_token: str


async def _hash_password(password: str) -> str:
    """bcrypt.hashpw is CPU-bound (~200-500ms); never run it on the event loop.

    Caller is responsible for length-validating ``password`` against the
    72-byte bcrypt cap before calling — bcrypt silently truncates beyond
    that, which is a correctness footgun, not a security one.
    """

    def _work() -> str:
        return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()

    return await asyncio.to_thread(_work)


# bcrypt's algorithm cap — the underlying primitive truncates input
# beyond this so two distinct passwords sharing a 72-byte prefix would
# verify identically. We reject up-front with a clear 422 message.
_BCRYPT_MAX_PASSWORD_BYTES = 72


def _check_bcrypt_length(password: str) -> None:
    if len(password.encode("utf-8")) > _BCRYPT_MAX_PASSWORD_BYTES:
        raise HTTPException(
            status_code=422,
            detail=(
                f"Password too long; bcrypt limit is "
                f"{_BCRYPT_MAX_PASSWORD_BYTES} bytes (longer passwords would "
                "be silently truncated)"
            ),
        )


async def _verify_password(password: str, hashed: str) -> bool:
    def _work() -> bool:
        try:
            return bcrypt.checkpw(password.encode(), hashed.encode())
        except ValueError:
            # Malformed hash in DB — treat as mismatch, never raise to caller.
            return False

    return await asyncio.to_thread(_work)


@router.post("/setup", response_model=TokenResponse)
@limiter.limit("3/minute")
async def setup(request: Request, body: SetupRequest, session: AsyncSession = Depends(get_session)):
    if len(body.password) < 8:
        raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
    _check_bcrypt_length(body.password)
    # Compute hash BEFORE opening the transaction so we don't hold a writer lock
    # during the CPU-bound bcrypt work.
    hashed = await _hash_password(body.password)

    # Serialize setup via an INSERT-inside-transaction-with-count-guard.
    # SQLite's writer lock plus the count check inside the transaction closes
    # the TOCTOU window between two concurrent POSTs. We ignore id=0 — that's
    # the internal "__system__" placeholder used for ownership of default
    # templates, never a real admin.
    async with session.begin():
        result = await session.exec(
            select(func.count()).select_from(User).where(User.id != 0)
        )
        count = result.one()
        if count > 0:
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail="Setup already completed.",
            )
        user = User(username=body.username, hashed_password=hashed, role="admin")
        session.add(user)
    await session.refresh(user)

    # Auto-create the bridge_self provider for the new admin so internal-
    # failure notifications work out of the box. Best-effort — a seeding
    # failure should not abort setup.
    try:
        from ..database.seeds import ensure_bridge_self_provider_for_user
        await ensure_bridge_self_provider_for_user(session, user.id)
        await session.commit()
    except Exception:  # noqa: BLE001
        await session.rollback()

    return TokenResponse(
        access_token=create_access_token(user.id, user.role, user.token_version),
        refresh_token=create_refresh_token(user.id, user.token_version),
    )


@router.post("/login", response_model=TokenResponse)
@limiter.limit("5/minute")
async def login(request: Request, body: LoginRequest, session: AsyncSession = Depends(get_session)):
    result = await session.exec(select(User).where(User.username == body.username))
    user = result.first()
    # Always run a bcrypt verification to keep the response time constant,
    # preventing username-enumeration via timing side channel.
    password_ok = await _verify_password(
        body.password,
        user.hashed_password if user else "$2b$12$" + "a" * 53,
    )
    if not user or not password_ok:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")

    return TokenResponse(
        access_token=create_access_token(user.id, user.role, user.token_version),
        refresh_token=create_refresh_token(user.id, user.token_version),
    )


@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("10/minute")
async def refresh(request: Request, body: RefreshRequest, session: AsyncSession = Depends(get_session)):
    import jwt as pyjwt
    try:
        payload = decode_token(body.refresh_token)
        if payload.get("type") != "refresh":
            raise HTTPException(status_code=401, detail="Invalid token type")
        user_id = int(payload["sub"])
        token_version = int(payload.get("ver", 1))
    except (pyjwt.PyJWTError, KeyError, ValueError) as exc:
        raise HTTPException(status_code=401, detail="Invalid refresh token") from exc

    user = await session.get(User, user_id)
    if not user:
        raise HTTPException(status_code=401, detail="User not found")
    if token_version != user.token_version:
        raise HTTPException(status_code=401, detail="Refresh token revoked")

    return TokenResponse(
        access_token=create_access_token(user.id, user.role, user.token_version),
        refresh_token=create_refresh_token(user.id, user.token_version),
    )


@router.get("/me", response_model=UserResponse)
async def me(user: User = Depends(get_current_user)):
    return UserResponse(id=user.id, username=user.username, role=user.role)


class PasswordChangeRequest(BaseModel):
    current_password: str
    new_password: str


@router.put("/password")
@limiter.limit("10/minute")
async def change_password(
    request: Request,
    body: PasswordChangeRequest,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    if not await _verify_password(body.current_password, user.hashed_password):
        raise HTTPException(status_code=400, detail="Current password is incorrect")
    if len(body.new_password) < 8:
        raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
    _check_bcrypt_length(body.new_password)
    user.hashed_password = await _hash_password(body.new_password)
    user.token_version = (user.token_version or 1) + 1
    session.add(user)
    await session.commit()
    return {"success": True}


@router.get("/needs-setup")
@limiter.limit("30/minute")
async def needs_setup(request: Request, session: AsyncSession = Depends(get_session)):
    # Exclude the internal __system__ placeholder (id=0) from the count so
    # a fresh install still reports needs_setup=True.
    result = await session.exec(
        select(func.count()).select_from(User).where(User.id != 0)
    )
    count = result.one()
    return {"needs_setup": count == 0}
