"""
Login endpoint — the one route (besides /health) that runs before any
token exists, so it can't depend on get_auth_context/get_db like every
other router. It opens its own session and sets the RLS tenant GUC from
the tenant_id supplied in the request body instead (see
app/core/database.py::get_db for the pattern this mirrors).
"""

import hashlib
import re
import uuid
from datetime import datetime, timezone
from typing import Literal
from urllib.parse import urlencode, urlsplit

from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession

from app.core import jobs
from app.core.auth import create_access_token, session_lifetime, verify_password
from app.core.database import get_db
from app.core.security import AuthContext, get_auth_context
from app.crud import account_emails, license_files, licensing, mfa, oidc, password_policy, sessions
from app.crud import sign_in_policy as policy
from app.crud.audit import write_audit_log
from app.crud.payments import public_api_url
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.core.recaptcha import RecaptchaError, verify_recaptcha_token
from app.crud.login_session import build_login_session_data
from app.crud.registration_fields import validate_builtin_registration_fields, validate_registration_field_values
from app.crud.user_verification import (
    VerificationError,
    check_code,
    create_challenge_token,
    decode_challenge_token,
    mask_phone,
    public_base_url,
    send_email_verification,
    send_sms_code,
    verification_channels,
)
from app.models.identity import Tenant, User, Vendor
from app.schemas.identity import (
    LicenseFileCheck,
    LicenseFileInput,
    RegistrationFieldDef,
    RegistrationSettings,
    TenantRead,
    TenantRegistrationFieldsRead,
    UserRead,
    VendorRegisterRequest,
    VendorRegisterResponse,
)

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

_INVALID_CREDENTIALS = "Invalid workspace ID, email, or password."


class LoginRequest(BaseModel):
    tenant_id: uuid.UUID
    email: EmailStr
    password: str
    # The widget's response token (Login.tsx) — required only when reCAPTCHA
    # is actually configured (see Settings.is_recaptcha_configured); a
    # deployment that never set it up needs no changes here at all.
    recaptcha_token: str | None = None


class LoginResponse(BaseModel):
    # All three are null while a second step is still required — see
    # verification_required below.
    access_token: str | None = None
    token_type: str = "bearer"
    user: UserRead | None = None
    tenant: TenantRead | None = None
    # "sms": the password was right but this account still has to confirm
    # the code just texted to it (first sign-in of an SMS-verified account)
    # — POST /auth/login/verify-otp with challenge_token + the code.
    # "totp": enter a code from the authenticator app (or a recovery code) —
    # POST /auth/login/verify-mfa. "mfa_setup": the workspace requires
    # two-factor sign-in and this account hasn't set it up — do it now
    # (POST /auth/login/mfa-setup, then /auth/login/mfa-setup/confirm).
    # "password_change": the password has expired, or an administrator set
    # it — choose a new one (POST /auth/login/change-password); `notice` says why.
    verification_required: Literal["sms", "totp", "mfa_setup", "password_change"] | None = None
    challenge_token: str | None = None
    phone_hint: str | None = None
    # Shown once, right after two-factor sign-in was set up during sign-in.
    recovery_codes: list[str] | None = None
    # Something worth telling the person as they come in (e.g. recovery codes running low).
    notice: str | None = None
    # Computed per the tenant's LoginSessionSettings (Users → Session data);
    # the browser stores it as the session's "{{session.<key>}}" values.
    session_data: dict = {}


class OtpChallengeRequest(BaseModel):
    tenant_id: uuid.UUID
    challenge_token: str


class VerifyLoginOtpRequest(OtpChallengeRequest):
    otp: str


class VerifyEmailRequest(BaseModel):
    tenant_id: uuid.UUID
    token: str
    otp: str


class ResendEmailVerificationRequest(BaseModel):
    tenant_id: uuid.UUID
    email: EmailStr
    password: str


class DetailResponse(BaseModel):
    detail: str
    phone_hint: str | None = None


_EMAIL_UNVERIFIED = {
    "code": "email_verification_required",
    "message": "Please verify your email address first — open the link in the verification email we sent you.",
}


async def _tenant_session(tenant_id: uuid.UUID):
    """Opens the pre-auth session every route here uses, scoped by RLS to
    the tenant named in the request body."""
    db = AsyncSessionLocal()
    await db.begin()
    await db.execute(text("SELECT set_config('app.current_tenant_id', :v, true)"), {"v": str(tenant_id)})
    return db


def _check_license(tenant: Tenant) -> None:
    """Outside its license dates nobody signs in to the tenant."""
    try:
        licensing.check_period(tenant)
    except licensing.LicenseError as exc:
        raise licensing.http_error(exc) from None


async def _session_response(db, user: User, tenant: Tenant, amr: list[str] | None = None) -> LoginResponse:
    """Signs the person in: records the session (app/crud/sessions.py) and
    issues its token."""
    _check_license(tenant)
    user.last_login_at = datetime.now(timezone.utc)
    policy.record_success(user)
    amr = amr or ["pwd"]
    lifetime = session_lifetime(policy.session_expiry(policy.settings_of(tenant)))
    session = await sessions.start(db, user, amr, lifetime)
    token = create_access_token(tenant_id=tenant.id, user_id=user.id, vendor_id=user.vendor_id, amr=amr, lifetime=lifetime, session_id=session.id)
    return LoginResponse(access_token=token, user=UserRead.model_validate(user), tenant=TenantRead.model_validate(tenant))


async def _complete(db, user: User, tenant: Tenant, amr: list[str], **extra) -> LoginResponse:
    """The session, with the user as saved and the session data computed."""
    response = await _session_response(db, user, tenant, amr)
    await db.flush()
    await db.refresh(user)
    response.user = UserRead.model_validate(user)
    response.session_data = await build_login_session_data(db, tenant, user)
    for key, value in extra.items():
        setattr(response, key, value)
    return response


async def _after_password(db, user: User, tenant: Tenant) -> LoginResponse:
    """The password (and any first-sign-in verification) checked out: a new
    password first, if the workspace's policy wants one (app/crud/
    password_policy.py), then a second factor, if the account has one or the
    workspace requires one — else the session."""
    reason = password_policy.change_needed(tenant, user)
    if reason:
        return LoginResponse(verification_required="password_change", challenge_token=mfa.challenge_token(user, "password_change"), notice=reason)
    if mfa.enabled(user):
        return LoginResponse(verification_required="totp", challenge_token=mfa.challenge_token(user, "mfa"))
    if policy.mfa_required(policy.settings_of(tenant), user):
        return LoginResponse(verification_required="mfa_setup", challenge_token=mfa.challenge_token(user, "mfa_setup"))
    return await _complete(db, user, tenant, ["pwd"])


def _raise(exc: VerificationError) -> None:
    raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None


class RecaptchaConfigResponse(BaseModel):
    enabled: bool
    site_key: str


@router.get("/recaptcha-config", response_model=RecaptchaConfigResponse)
async def get_recaptcha_config() -> RecaptchaConfigResponse:
    """Public, unauthenticated — the login PAGE itself needs to know whether
    to render the widget before anyone has signed in. `site_key` is only
    ever the public site key (safe to ship to a browser); empty when
    reCAPTCHA isn't configured, same as `enabled` being false."""
    if not settings.is_recaptcha_configured:
        return RecaptchaConfigResponse(enabled=False, site_key="")
    return RecaptchaConfigResponse(enabled=True, site_key=settings.recaptcha_site_key)


@router.get("/password-policy", response_model=password_policy.PasswordPolicyRead)
async def get_password_policy(tenant_id: uuid.UUID) -> password_policy.PasswordPolicyRead:
    """Public — the sign-up, reset and change-password pages show a
    workspace's password rules as the person types (the server checks them
    again when the password is set). An unknown workspace gets the defaults."""
    db = await _tenant_session(tenant_id)
    try:
        tenant = await db.get(Tenant, tenant_id)
        rules = password_policy.policy_of(tenant)
    finally:
        await db.close()
    return password_policy.PasswordPolicyRead(**rules.model_dump(), requirements=password_policy.requirements(rules))


class MapsConfigResponse(BaseModel):
    google_maps_api_key: str


@router.get("/maps-config", response_model=MapsConfigResponse)
async def get_maps_config() -> MapsConfigResponse:
    """Public, like /recaptcha-config — the geolocation map picker needs the
    Google Maps browser key before loading the Maps script. A browser key is
    meant to ship to the browser (it's locked to allowed domains in Google
    Cloud Console, not kept secret)."""
    return MapsConfigResponse(google_maps_api_key=settings.google_maps_api_key.strip())


class SignInTenant(BaseModel):
    id: uuid.UUID
    name: str


@router.get("/tenants", response_model=list[SignInTenant])
async def list_sign_in_tenants() -> list[SignInTenant]:
    """Public, unauthenticated — the sign-in and registration screens'
    tenant dropdown. Active tenants only, and only their id and name.
    Empty when Settings.public_tenant_list is off (the screens then fall
    back to asking for the tenant ID)."""
    if not settings.public_tenant_list:
        return []
    async with AsyncSessionLocal() as db:
        rows = (await db.execute(select(Tenant.id, Tenant.name).where(Tenant.status == "active").order_by(func.lower(Tenant.name)))).all()
    return [SignInTenant(id=tid, name=name) for tid, name in rows]


@router.post("/login", response_model=LoginResponse)
async def login(payload: LoginRequest, request: Request) -> LoginResponse:
    try:
        await verify_recaptcha_token(payload.recaptcha_token, request.client.host if request.client else None)
    except RecaptchaError as exc:
        raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc

    db = await _tenant_session(payload.tenant_id)
    try:
        tenant = (await db.execute(select(Tenant).where(Tenant.id == payload.tenant_id))).scalar_one_or_none()
        if tenant is None:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_INVALID_CREDENTIALS)
        rules = policy.settings_of(tenant)
        user = (
            await db.execute(select(User).where(User.tenant_id == payload.tenant_id, User.email == payload.email))
        ).scalar_one_or_none()
        if user is None:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_INVALID_CREDENTIALS)
        locked = policy.locked_message(user)
        if locked:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"code": "locked", "message": locked})
        if not user.password_hash or not verify_password(payload.password, user.password_hash):
            policy.record_failure(rules, user)
            await db.commit()
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=policy.locked_message(user) or _INVALID_CREDENTIALS)
        if user.status != "active":
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This account is inactive.")
        # Before any verification step, so nobody is asked for a code first.
        _check_license(tenant)
        if not policy.password_allowed(rules, user):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail={"code": "sso_required", "message": f"This workspace signs in through single sign-on — use “{rules.sso.button_label}”."},
            )

        # Account verification an admin required at creation (see
        # app/crud/user_verification.py).
        # "both" runs these in order: email first, then SMS.
        if user.needs_email_verification:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_EMAIL_UNVERIFIED)
        if user.needs_sms_verification:
            try:
                await send_sms_code(db, user, tenant)
            except VerificationError as exc:
                _raise(exc)
            await db.commit()
            return LoginResponse(
                verification_required="sms",
                challenge_token=create_challenge_token(user),
                phone_hint=mask_phone(user.phone),
            )

        response = await _after_password(db, user, tenant)
        await db.commit()
        return response
    finally:
        await db.close()


@router.post("/login/verify-otp", response_model=LoginResponse)
async def verify_login_otp(payload: VerifyLoginOtpRequest) -> LoginResponse:
    """Second step of an SMS-verified account's first sign-in: the code
    texted by /auth/login. On success the account is verified for good, and
    sign-in carries on (a second factor, if any, else the session)."""
    db = await _tenant_session(payload.tenant_id)
    try:
        try:
            user_id = decode_challenge_token(payload.challenge_token, payload.tenant_id)
        except VerificationError as exc:
            _raise(exc)
        user = await db.get(User, user_id)
        tenant = await db.get(Tenant, payload.tenant_id)
        if user is None or tenant is None or user.tenant_id != tenant.id or user.status != "active":
            raise HTTPException(status_code=400, detail="This sign-in attempt has expired — sign in again.")
        try:
            check_code(user, payload.otp, channel="sms")
        except VerificationError as exc:
            await db.commit()  # keep the wrong-attempt count
            _raise(exc)
        response = await _after_password(db, user, tenant)
        await db.commit()
        return response
    finally:
        await db.close()


# ---- two-factor sign-in (app/crud/mfa.py) ------------------------------------------------------------------


class MfaChallengeRequest(BaseModel):
    tenant_id: uuid.UUID
    challenge_token: str


class MfaCodeRequest(MfaChallengeRequest):
    code: str


class MfaSetupResponse(BaseModel):
    secret: str
    otpauth_uri: str
    qr_svg: str


def _mfa_http(exc: mfa.MfaError) -> HTTPException:
    return HTTPException(status_code=exc.status_code, detail=str(exc))


async def _challenged_user(db, payload: MfaChallengeRequest, purpose: str) -> tuple[User, Tenant]:
    try:
        user_id = mfa.read_challenge(payload.challenge_token, payload.tenant_id, purpose)
    except mfa.MfaError as exc:
        raise _mfa_http(exc) from None
    user = await db.get(User, user_id)
    tenant = await db.get(Tenant, payload.tenant_id)
    if user is None or tenant is None or user.tenant_id != tenant.id or user.status != "active":
        raise HTTPException(status_code=400, detail="This sign-in attempt has expired — sign in again.")
    locked = policy.locked_message(user)
    if locked:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"code": "locked", "message": locked})
    return user, tenant


@router.post("/login/verify-mfa", response_model=LoginResponse)
async def verify_login_mfa(payload: MfaCodeRequest) -> LoginResponse:
    """The code from the authenticator app — or a recovery code — after the password."""
    db = await _tenant_session(payload.tenant_id)
    try:
        user, tenant = await _challenged_user(db, payload, "mfa")
        try:
            used = mfa.check(user, payload.code)
        except mfa.MfaError as exc:
            policy.record_failure(policy.settings_of(tenant), user)
            await db.commit()
            raise HTTPException(status_code=400, detail=policy.locked_message(user) or str(exc)) from None
        notice = None
        if used == "recovery":
            left = len(user.mfa_recovery_hashes or [])
            notice = f"You used a recovery code — {left} left." + (" Make new ones under Account → Security." if left <= 3 else "")
        response = await _complete(db, user, tenant, ["pwd", "otp"], notice=notice)
        await db.commit()
        return response
    finally:
        await db.close()


class LoginPasswordChange(MfaChallengeRequest):
    new_password: str = Field(min_length=1, max_length=256)


@router.post("/login/change-password", response_model=LoginResponse)
async def change_password_at_login(payload: LoginPasswordChange) -> LoginResponse:
    """The new password asked for at sign-in (an expired one, or one an
    administrator set) — checked against the workspace's policy, then sign-in
    carries on: a second factor if there is one, else the session."""
    db = await _tenant_session(payload.tenant_id)
    try:
        user, tenant = await _challenged_user(db, payload, "password_change")
        if user.password_hash and verify_password(payload.new_password, user.password_hash):
            raise HTTPException(status_code=422, detail="Choose a new password, different from your current one.")
        password_policy.set_password(tenant, user, payload.new_password)
        account_emails.clear_reset(user)
        # Any session still open on the old password ends (not every token
        # from before now — the session about to start is one of those).
        await sessions.revoke_others(db, tenant.id, user.id, keep=None)
        await write_audit_log(db, tenant.id, user.id, "user.password_changed", "user", user.id, {"at_sign_in": True})
        await account_emails.queue(db, tenant, "password_changed", user)
        response = await _after_password(db, user, tenant)
        await db.commit()
        return response
    finally:
        await db.close()


@router.post("/login/mfa-setup", response_model=MfaSetupResponse)
async def start_login_mfa_setup(payload: MfaChallengeRequest) -> MfaSetupResponse:
    """The workspace requires two-factor sign-in: a new authenticator secret
    for this account, shown as a QR code."""
    db = await _tenant_session(payload.tenant_id)
    try:
        user, tenant = await _challenged_user(db, payload, "mfa_setup")
        try:
            setup = mfa.start_setup(user, tenant)
        except mfa.MfaError as exc:
            raise _mfa_http(exc) from None
        await db.commit()
        return MfaSetupResponse(**setup)
    finally:
        await db.close()


@router.post("/login/mfa-setup/confirm", response_model=LoginResponse)
async def confirm_login_mfa_setup(payload: MfaCodeRequest) -> LoginResponse:
    """A code from the newly set-up app turns two-factor on and signs in,
    with the recovery codes (shown this once)."""
    db = await _tenant_session(payload.tenant_id)
    try:
        user, tenant = await _challenged_user(db, payload, "mfa_setup")
        try:
            codes = mfa.confirm_setup(user, payload.code)
        except mfa.MfaError as exc:
            policy.record_failure(policy.settings_of(tenant), user)
            await db.commit()
            raise HTTPException(status_code=exc.status_code, detail=policy.locked_message(user) or str(exc)) from None
        response = await _complete(db, user, tenant, ["pwd", "otp"], recovery_codes=codes)
        await db.commit()
        return response
    finally:
        await db.close()


@router.post("/login/resend-otp", response_model=DetailResponse)
async def resend_login_otp(payload: OtpChallengeRequest) -> DetailResponse:
    db = await _tenant_session(payload.tenant_id)
    try:
        try:
            user_id = decode_challenge_token(payload.challenge_token, payload.tenant_id)
        except VerificationError as exc:
            _raise(exc)
        user = await db.get(User, user_id)
        tenant = await db.get(Tenant, payload.tenant_id)
        if user is None or tenant is None or not user.needs_sms_verification:
            raise HTTPException(status_code=400, detail="This sign-in attempt has expired — sign in again.")
        try:
            await send_sms_code(db, user, tenant, enforce_cooldown=True)
        except VerificationError as exc:
            _raise(exc)
        await db.commit()
        return DetailResponse(detail="A new code is on its way.", phone_hint=mask_phone(user.phone))
    finally:
        await db.close()


@router.post("/verify-email", response_model=DetailResponse)
async def verify_email(payload: VerifyEmailRequest) -> DetailResponse:
    """The /verify-email page (opened from the verification email's link)
    submits the link's token + the code from the same email."""
    db = await _tenant_session(payload.tenant_id)
    try:
        token_hash = hashlib.sha256(payload.token.encode()).hexdigest()
        user = (
            await db.execute(
                select(User).where(User.tenant_id == payload.tenant_id, User.verification_token_hash == token_hash)
            )
        ).scalar_one_or_none()
        if user is None:
            raise HTTPException(
                status_code=400,
                detail="This verification link isn't valid any more — it may have been used already, or a newer one was sent. Try signing in.",
            )
        try:
            check_code(user, payload.otp, channel="email", token=payload.token)
        except VerificationError as exc:
            await db.commit()  # keep the wrong-attempt count
            _raise(exc)
        await db.commit()
        return DetailResponse(detail="Your email address is verified — you can sign in now.")
    finally:
        await db.close()


@router.post("/resend-email-verification", response_model=DetailResponse)
async def resend_email_verification(payload: ResendEmailVerificationRequest, request: Request) -> DetailResponse:
    """Offered by the sign-in page after /auth/login refuses a still-
    unverified email account. Needs the password, so it can't be used to
    spam someone else's inbox."""
    db = await _tenant_session(payload.tenant_id)
    try:
        tenant = await db.get(Tenant, payload.tenant_id)
        user = (
            await db.execute(select(User).where(User.tenant_id == payload.tenant_id, User.email == payload.email))
        ).scalar_one_or_none()
        if tenant is None or user is None or not user.password_hash or not verify_password(payload.password, user.password_hash):
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_INVALID_CREDENTIALS)
        if not user.needs_email_verification:
            raise HTTPException(status_code=409, detail="This account doesn't need email verification — sign in.")
        try:
            delivery = await send_email_verification(
                db, user, tenant, public_base_url(request.headers.get("origin"), request.headers.get("referer")), enforce_cooldown=True
            )
        except VerificationError as exc:
            _raise(exc)
        await db.commit()
        if delivery != "sent":
            raise HTTPException(status_code=503, detail="We couldn't send the email right now — try again later or contact your administrator.")
        return DetailResponse(detail=f"A new verification email has been sent to {user.email}.")
    finally:
        await db.close()


@router.get("/register/fields", response_model=TenantRegistrationFieldsRead)
async def get_registration_fields(tenant_id: uuid.UUID) -> TenantRegistrationFieldsRead:
    """Public — Register.tsx calls this once the Workspace ID field holds a
    valid UUID, so it can render this tenant's extra registration fields
    (Tenant.registration_field_schema, set via PATCH /tenants/me) before the
    registrant ever submits the form."""
    async with AsyncSessionLocal() as db:
        async with db.begin():
            await db.execute(
                text("SELECT set_config('app.current_tenant_id', :v, true)"), {"v": str(tenant_id)}
            )
            tenant = (await db.execute(select(Tenant).where(Tenant.id == tenant_id))).scalar_one_or_none()
            if tenant is None:
                raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found.")
            return TenantRegistrationFieldsRead(
                tenant_name=tenant.name,
                fields=[RegistrationFieldDef.model_validate(f) for f in tenant.registration_field_schema],
                settings=RegistrationSettings.model_validate(tenant.registration_settings or {}),
            )


@router.post("/register", response_model=VendorRegisterResponse, status_code=status.HTTP_201_CREATED)
async def register_vendor_user(payload: VendorRegisterRequest, request: Request) -> VendorRegisterResponse:
    """Public Portal self-registration. The account is created inactive
    with no role — an internal admin must review it on the Users page,
    assign a role, and activate it before the person can sign in (FR-PUB-*
    mirrors the same admin-approval posture as FR-RBA-001's tenant
    provisioning: nobody gets standing access without an admin granting it).

    Every field is checked against the tenant's registration config
    (required-ness + validation rules — app/crud/registration_fields.py).
    If the tenant's RegistrationSettings.verification requires it, the new
    account must also verify — not as part of this form: an email link +
    code goes out right after registering, and/or a texted code is asked
    for at first sign-in (app/crud/user_verification.py)."""
    async with AsyncSessionLocal() as db:
        async with db.begin():
            await db.execute(
                text("SELECT set_config('app.current_tenant_id', :v, true)"), {"v": str(payload.tenant_id)}
            )

            tenant = (
                await db.execute(select(Tenant).where(Tenant.id == payload.tenant_id))
            ).scalar_one_or_none()
            if tenant is None:
                raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found.")
            if licensing.tenant_message(tenant):
                raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This workspace isn't accepting registrations right now.")

            existing = (
                await db.execute(
                    select(User.id).where(User.tenant_id == payload.tenant_id, User.email == payload.email)
                )
            ).scalar_one_or_none()
            if existing is not None:
                raise HTTPException(
                    status_code=status.HTTP_409_CONFLICT,
                    detail="An account with this email already exists in this workspace.",
                )

            settings_ = RegistrationSettings.model_validate(tenant.registration_settings or {})
            field_schema = [RegistrationFieldDef.model_validate(f) for f in tenant.registration_field_schema]
            try:
                validate_builtin_registration_fields(
                    settings_,
                    {"organization_name": payload.organization_name, "name": payload.name, "phone": payload.phone},
                )
                extra_fields = validate_registration_field_values(field_schema, payload.extra_fields)
            except ValueError as exc:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
            if settings_.phone.hidden:
                payload.phone = None

            vendor = (
                await db.execute(
                    select(Vendor).where(
                        Vendor.tenant_id == payload.tenant_id,
                        func.lower(Vendor.organization_name) == payload.organization_name.strip().lower(),
                    )
                )
            ).scalar_one_or_none()
            if vendor is None:
                vendor = Vendor(tenant_id=payload.tenant_id, organization_name=payload.organization_name.strip())
                db.add(vendor)
                await db.flush()

            user = User(
                tenant_id=payload.tenant_id,
                vendor_id=vendor.id,
                name=payload.name,
                email=payload.email,
                phone=payload.phone,
                status="inactive",
                extra_fields=extra_fields,
                verification_method=None if settings_.verification == "none" else settings_.verification,
            )
            password_policy.set_password(tenant, user, payload.password)
            db.add(user)
            await db.flush()
            await account_emails.queue(db, tenant, "registration_received", user, {"organization": vendor.organization_name})

            email_sent = None
            if "email" in verification_channels(settings_.verification):
                # A delivery problem (or a connector removed since the
                # admin chose email verification) mustn't lose the
                # registration — the admin can resend from the Users page.
                try:
                    delivery = await send_email_verification(
                        db, user, tenant, public_base_url(request.headers.get("origin"), request.headers.get("referer"))
                    )
                except VerificationError:
                    delivery = "failed"
                email_sent = delivery == "sent"

            detail = "Registration received. An administrator will review and activate your account."
            if email_sent:
                detail += f" We've also emailed a verification link to {user.email} — please open it to verify your email address."
            elif email_sent is False:
                detail += " We couldn't send your verification email right now — an administrator can re-send it."
            if "sms" in verification_channels(settings_.verification):
                detail += " The first time you sign in, we'll text a code to your phone to confirm it."
            return VendorRegisterResponse(detail=detail, verification=settings_.verification, verification_email_sent=email_sent)


# ---- the signed-in person's own two-factor settings --------------------------------------------------------


class MfaStatus(BaseModel):
    enabled: bool
    enabled_at: datetime | None
    recovery_codes_left: int
    # The workspace requires it for this account — it can't be turned off.
    required: bool


class CodeRequest(BaseModel):
    code: str


class RecoveryCodes(BaseModel):
    recovery_codes: list[str]


async def _me(db: AsyncSession, ctx: AuthContext) -> tuple[User, Tenant]:
    user = await db.get(User, ctx.user_id) if ctx.user_id else None
    tenant = await db.get(Tenant, ctx.tenant_id)
    if user is None or tenant is None or user.tenant_id != ctx.tenant_id:
        raise HTTPException(status_code=404, detail="Account not found")
    return user, tenant


def _status(user: User, tenant: Tenant) -> MfaStatus:
    return MfaStatus(
        enabled=mfa.enabled(user), enabled_at=user.mfa_enabled_at, recovery_codes_left=len(user.mfa_recovery_hashes or []),
        required=policy.mfa_required(policy.settings_of(tenant), user),
    )


def _check_own_code(user: User, tenant: Tenant, code: str) -> None:
    try:
        mfa.check(user, code)
    except mfa.MfaError as exc:
        policy.record_failure(policy.settings_of(tenant), user)
        raise HTTPException(status_code=400, detail=str(exc)) from None


class ChangePasswordRequest(BaseModel):
    current_password: str
    new_password: str = Field(min_length=1, max_length=256)  # the workspace's password policy decides


async def _record_failure_durably(tenant_id: uuid.UUID, user_id: uuid.UUID, rules) -> None:
    """A wrong password or code, kept even though the request fails — the
    request's own transaction rolls back with it."""
    async with jobs.tenant_session(tenant_id) as session:
        user = await session.get(User, user_id)
        if user is not None:
            policy.record_failure(rules, user)


@router.post("/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_my_password(payload: ChangePasswordRequest, ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> None:
    """Changes your own password. Needs the current one; wrong guesses count
    toward the lockout like failed sign-ins."""
    user, tenant = await _me(db, ctx)
    if not user.password_hash:
        raise HTTPException(status_code=409, detail="Your account signs in through single sign-on — change your password with your identity provider.")
    rules = policy.settings_of(tenant)
    locked = policy.locked_message(user)
    if locked:
        raise HTTPException(status_code=403, detail=locked)
    if not verify_password(payload.current_password, user.password_hash):
        await _record_failure_durably(ctx.tenant_id, user.id, rules)
        raise HTTPException(status_code=400, detail="Your current password is incorrect.")
    if verify_password(payload.new_password, user.password_hash):
        raise HTTPException(status_code=422, detail="Choose a new password, different from your current one.")
    password_policy.set_password(tenant, user, payload.new_password)
    policy.record_success(user)
    account_emails.clear_reset(user)
    # Anywhere else they're signed in might be whoever knew the old password.
    await sessions.revoke_others(db, ctx.tenant_id, user.id, keep=ctx.session_id)
    await write_audit_log(db, ctx.tenant_id, ctx.user_id, "user.password_changed", "user", user.id)
    await account_emails.queue(db, tenant, "password_changed", user)


# ---- forgot password (app/crud/account_emails.py) -------------------------------------------------------


class ForgotPasswordRequest(BaseModel):
    tenant_id: uuid.UUID
    email: EmailStr
    recaptcha_token: str | None = None


class ResetPasswordRequest(BaseModel):
    tenant_id: uuid.UUID
    token: str = Field(min_length=10, max_length=200)
    new_password: str = Field(min_length=1, max_length=256)  # the workspace's password policy decides


_RESET_SENT = "If there's an account with that email, we've sent it a link to reset the password. It works for 30 minutes."


@router.post("/password/forgot", response_model=DetailResponse)
async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> DetailResponse:
    """Emails a single-use reset link. Answers the same whether or not the
    account exists — and the email goes out in the background, so the time
    taken doesn't tell either."""
    try:
        await verify_recaptcha_token(payload.recaptcha_token, request.client.host if request.client else None)
    except RecaptchaError as exc:
        raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
    db = await _tenant_session(payload.tenant_id)
    try:
        tenant = await db.get(Tenant, payload.tenant_id)
        if tenant is not None and not licensing.tenant_message(tenant):
            user = (await db.execute(select(User).where(User.tenant_id == tenant.id, User.email == payload.email))).scalar_one_or_none()
            if user is not None and not policy.password_allowed(policy.settings_of(tenant), user):
                user = None  # signs in through single sign-on only
            base_url = public_base_url(request.headers.get("origin"), request.headers.get("referer"))
            await account_emails.request_reset(db, tenant, user, base_url)
            await db.commit()
    finally:
        await db.close()
    return DetailResponse(detail=_RESET_SENT)


@router.post("/password/reset", response_model=DetailResponse)
async def reset_password(payload: ResetPasswordRequest) -> DetailResponse:
    """Sets a new password from a reset link. Lifts a lockout too — the
    link proves who they are."""
    db = await _tenant_session(payload.tenant_id)
    try:
        tenant = await db.get(Tenant, payload.tenant_id)
        user = await account_emails.user_for_reset(db, payload.tenant_id, payload.token) if tenant else None
        if user is None:
            raise HTTPException(status_code=400, detail="This reset link has expired or was already used. Ask for a new one.")
        password_policy.set_password(tenant, user, payload.new_password)
        account_emails.clear_reset(user)
        policy.record_success(user)
        await sessions.sign_out_everywhere(db, user)
        await write_audit_log(db, tenant.id, user.id, "user.password_reset_by_link", "user", user.id)
        await account_emails.queue(db, tenant, "password_changed", user)
        await db.commit()
    finally:
        await db.close()
    return DetailResponse(detail="Your password has been changed. You can sign in with it now.")


# ---- your sessions (app/crud/sessions.py) -----------------------------------------------------------------


@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> None:
    """Signs this session out, so its token stops working now rather than when it expires."""
    if ctx.session_id:
        await sessions.revoke(db, ctx.tenant_id, ctx.session_id, user_id=ctx.user_id)


@router.get("/sessions", response_model=list[sessions.SessionRead])
async def my_sessions(ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> list[sessions.SessionRead]:
    """Where you're signed in."""
    if ctx.user_id is None:
        return []
    return await sessions.live(db, ctx.tenant_id, user_id=ctx.user_id, current=ctx.session_id)


@router.post("/sessions/{session_id}/revoke", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_my_session(session_id: uuid.UUID, ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> None:
    if not ctx.user_id or not await sessions.revoke(db, ctx.tenant_id, session_id, user_id=ctx.user_id):
        raise HTTPException(status_code=404, detail="Session not found.")
    await write_audit_log(db, ctx.tenant_id, ctx.user_id, "user.session_revoked", "user", ctx.user_id, {"session_id": str(session_id)})


@router.post("/sessions/revoke-others", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_my_other_sessions(ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> None:
    """Signs out everywhere but here."""
    if ctx.user_id:
        await sessions.revoke_others(db, ctx.tenant_id, ctx.user_id, keep=ctx.session_id)
        await write_audit_log(db, ctx.tenant_id, ctx.user_id, "user.sessions_revoked", "user", ctx.user_id, {"kept": str(ctx.session_id)})


@router.get("/mfa", response_model=MfaStatus)
async def my_mfa(ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> MfaStatus:
    user, tenant = await _me(db, ctx)
    return _status(user, tenant)


@router.post("/mfa/setup", response_model=MfaSetupResponse)
async def start_my_mfa_setup(ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> MfaSetupResponse:
    user, tenant = await _me(db, ctx)
    try:
        return MfaSetupResponse(**mfa.start_setup(user, tenant))
    except mfa.MfaError as exc:
        raise _mfa_http(exc) from None


@router.post("/mfa/confirm", response_model=RecoveryCodes)
async def confirm_my_mfa_setup(payload: CodeRequest, ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> RecoveryCodes:
    user, _ = await _me(db, ctx)
    try:
        codes = mfa.confirm_setup(user, payload.code)
    except mfa.MfaError as exc:
        raise _mfa_http(exc) from None
    await write_audit_log(db, ctx.tenant_id, ctx.user_id, "user.mfa.enable", "user", user.id, {})
    return RecoveryCodes(recovery_codes=codes)


@router.post("/mfa/disable", response_model=MfaStatus)
async def disable_my_mfa(payload: CodeRequest, ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> MfaStatus:
    """Needs a current code (or a recovery code) — a session left open
    somewhere can't quietly remove the second factor."""
    user, tenant = await _me(db, ctx)
    if policy.mfa_required(policy.settings_of(tenant), user):
        raise HTTPException(status_code=409, detail="This workspace requires two-factor sign-in, so it can't be turned off.")
    _check_own_code(user, tenant, payload.code)
    mfa.disable(user)
    await write_audit_log(db, ctx.tenant_id, ctx.user_id, "user.mfa.disable", "user", user.id, {})
    return _status(user, tenant)


@router.post("/mfa/recovery-codes", response_model=RecoveryCodes)
async def new_my_recovery_codes(payload: CodeRequest, ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> RecoveryCodes:
    """Ten new recovery codes; the old ones stop working."""
    user, tenant = await _me(db, ctx)
    _check_own_code(user, tenant, payload.code)
    return RecoveryCodes(recovery_codes=mfa.new_recovery_codes(user))


# ---- the current session -------------------------------------------------------------------------------------


@router.get("/session", response_model=LoginResponse)
async def current_session(ctx: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db)) -> LoginResponse:
    """Who this token signs in as — what a sign-in returns, minus a new
    token. The web app uses it after single sign-on hands it a token."""
    user, tenant = await _me(db, ctx)
    if user.status != "active":
        raise HTTPException(status_code=403, detail="This account is inactive.")
    return LoginResponse(
        user=UserRead.model_validate(user), tenant=TenantRead.model_validate(tenant), session_data=await build_login_session_data(db, tenant, user)
    )


# ---- license files, while a workspace can't be signed in to -----------------------------------------------


async def _signed_out_license_session(content: str):
    """The tenant a license file is for, and a session scoped to it — only
    while that tenant is outside its license dates, when nobody can sign in
    to apply a renewal. The file itself is the authority there: encrypted,
    issued for that one tenant, and single-use (app/crud/license_files.py).
    Otherwise it's applied from inside the Studio, by an administrator."""
    try:
        tenant_id = license_files.tenant_of(content)
    except license_files.LicenseFileError as exc:
        raise license_files.http_error(exc) from None
    db = await _tenant_session(tenant_id)
    tenant = await db.get(Tenant, tenant_id)
    if tenant is None:
        await db.close()
        raise HTTPException(status_code=422, detail="This license file is for a workspace that no longer exists.")
    if not licensing.tenant_message(tenant):
        await db.close()
        raise HTTPException(
            status_code=409,
            detail="This workspace's license is in effect. Sign in and apply the file from Users → License.",
        )
    return db, tenant


@router.post("/license-file/check", response_model=LicenseFileCheck)
async def check_license_file_signed_out(payload: LicenseFileInput) -> LicenseFileCheck:
    db, tenant = await _signed_out_license_session(payload.content)
    try:
        _, checked = await license_files.check(db, tenant, payload.content)
        return checked
    except license_files.LicenseFileError as exc:
        raise license_files.http_error(exc) from None
    finally:
        await db.close()


@router.post("/license-file/apply", response_model=DetailResponse)
async def apply_license_file_signed_out(payload: LicenseFileInput) -> DetailResponse:
    db, tenant = await _signed_out_license_session(payload.content)
    try:
        await license_files.apply(db, tenant, payload.content, None)
        await db.commit()
    except license_files.LicenseFileError as exc:
        raise license_files.http_error(exc) from None
    finally:
        await db.close()
    return DetailResponse(detail=f"The license for {tenant.name} is updated. You can sign in now.")


# ---- single sign-on (app/crud/oidc.py) --------------------------------------------------------------------


class SsoConfig(BaseModel):
    enabled: bool
    button_label: str
    # Internal users can only sign in this way.
    required: bool
    # "Forgot password?" can send a reset email (app/crud/account_emails.py).
    password_reset: bool = False


_LOCAL_ORIGIN = re.compile(r"^http://localhost(:\d+)?$")


def _allowed_return(url: str) -> bool:
    """Single sign-on only hands a session back to the web app's own pages."""
    parts = urlsplit(url)
    origin = f"{parts.scheme}://{parts.netloc}"
    allowed = set(settings.cors_origin_list)
    if settings.app_base_url:
        allowed.add(settings.app_base_url.rstrip("/"))
    return parts.scheme in ("http", "https") and (origin in allowed or bool(_LOCAL_ORIGIN.match(origin)))


def sso_redirect_uri() -> str:
    return f"{public_api_url()}/auth/sso/callback"


@router.get("/sso-config", response_model=SsoConfig)
async def sso_config(tenant_id: uuid.UUID) -> SsoConfig:
    db = await _tenant_session(tenant_id)
    try:
        tenant = await db.get(Tenant, tenant_id)
        rules = policy.settings_of(tenant) if tenant else policy.SecuritySettings()
        reset = bool(tenant) and not licensing.tenant_message(tenant) and await account_emails.available(db, tenant, "password_reset")
        return SsoConfig(
            enabled=rules.sso.enabled, button_label=rules.sso.button_label, required=rules.sso.enabled and rules.sso.require_sso, password_reset=reset
        )
    finally:
        await db.close()


@router.get("/sso/start", response_model=None)
async def sso_start(tenant_id: uuid.UUID, return_to: str = Query(..., max_length=2000)) -> RedirectResponse:
    """Sends the browser to the workspace's identity provider. `return_to`
    is the web app's page that receives the session afterwards."""
    if not _allowed_return(return_to):
        raise HTTPException(status_code=400, detail="That return address isn't one of this app's own.")
    db = await _tenant_session(tenant_id)
    try:
        tenant = await db.get(Tenant, tenant_id)
        if tenant is None:
            raise HTTPException(status_code=404, detail="Workspace not found")
        try:
            url = await oidc.start(db, tenant, policy.settings_of(tenant), sso_redirect_uri(), return_to)
        except oidc.OidcError as exc:
            await db.rollback()
            return RedirectResponse(f"{return_to}?{urlencode({'sso_error': str(exc)})}", status_code=302)
        await db.commit()
        return RedirectResponse(url, status_code=302)
    finally:
        await db.close()


def _sso_failed_page(message: str) -> HTMLResponse:
    from html import escape

    return HTMLResponse(
        f"<!doctype html><meta charset=utf-8><title>Sign-in failed</title><body style='font:15px system-ui;max-width:32rem;margin:15vh auto;padding:0 1rem'>"
        f"<h1 style='font-size:1.2rem'>Single sign-on didn't work</h1><p>{escape(message)}</p><p>Go back to the sign-in page and try again.</p>",
        status_code=400,
    )


@router.get("/sso/callback", response_model=None)
async def sso_callback(
    state: str | None = None, code: str | None = None, error: str | None = None, error_description: str | None = None
) -> RedirectResponse | HTMLResponse:
    """Where the identity provider sends the browser back. The session goes
    to the web app in the URL fragment (#sso_token=…), which browsers never
    send to a server — so it doesn't end up in any access log."""
    async with AsyncSessionLocal() as db:
        async with db.begin():
            try:
                attempt = await oidc.take_state(db, state or "")
            except oidc.OidcError as exc:
                return _sso_failed_page(str(exc))
    return_to, tenant_id = attempt["return_to"], attempt["tenant_id"]

    def failed(message: str) -> RedirectResponse:
        return RedirectResponse(f"{return_to}?{urlencode({'sso_error': message})}", status_code=302)

    if error or not code:
        return failed(f"The identity provider said: {error_description or error or 'no sign-in code'}")
    db = await _tenant_session(tenant_id)
    try:
        tenant = await db.get(Tenant, tenant_id)
        rules = policy.settings_of(tenant)
        license_message = licensing.tenant_message(tenant)
        if license_message:
            await db.rollback()
            return failed(license_message)
        try:
            claims = await oidc.exchange(rules, code, attempt["code_verifier"], sso_redirect_uri(), attempt["nonce"])
            user = await oidc.resolve_user(db, tenant, rules, claims)
        except oidc.OidcError as exc:
            await db.rollback()
            return failed(str(exc))
        locked = policy.locked_message(user)
        if locked:
            await db.rollback()
            return failed(locked)
        # The provider did the person's second factor, if it has one.
        response = await _session_response(db, user, tenant, ["sso"])
        await write_audit_log(db, tenant.id, user.id, "user.sso_sign_in", "user", user.id, {"subject": user.external_auth_id})
        await db.commit()
        return RedirectResponse(f"{return_to}#{urlencode({'sso_token': response.access_token})}", status_code=302)
    finally:
        await db.close()

