"""
Super Admin: a platform-wide account type outside the tenant model (see
db/schema.sql's platform_admins table and app/core/security.py's
PlatformAdminContext/get_platform_admin_context). Everything under this
router's /admin prefix is gated behind that context — never get_auth_context
— so a regular tenant session can never reach any route here and vice versa.

Tenant/platform_admin CRUD uses get_unscoped_db (those two tables have no
Row-Level Security). Cross-tenant user/master/record CRUD "sudos" into one
tenant's RLS context via get_admin_scoped_db(tenant_id) and then reuses the
exact same CRUDBase instances and validation helpers the regular per-tenant
routers already call — no new data-access logic, no RLS policy changes.
"""

import uuid
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession

from app.core import platform_settings
from app.core.auth import create_platform_admin_token, hash_password, verify_password
from app.core.database import AsyncSessionLocal, get_admin_scoped_db, get_unscoped_db
from app.core.form_validation import field_errors_detail, missing_required_fields, validate_field_values
from app.core.security import PlatformAdminContext, get_platform_admin_context
from app.core.timezones import listed_timezones
from app.crud import account_emails, entity_storage, license_files, license_requests, licensing, password_policy, sessions
from app.crud.audit import write_audit_log
from app.crud.base import CRUDBase
from app.crud.formula import compute_formula_fields
from app.crud.tenant_provisioning import seed_baseline_roles
from app.crud import field_constraints
from app.crud.validation import validate_field_values
from app.models.identity import LicenseFile, Role, Tenant, User, UserRole
from app.models.master import Master, MasterRecord
from app.models.platform_admin import PlatformAdmin
from app.models.workflow import Form, FormSubmission
from app.routers.forms import _submissions_crud as form_submissions_crud
from app.routers.masters import crud as master_crud
from app.routers.masters import records_crud
from app.routers.roles import crud as role_crud
from app.schemas.identity import (
    IssuedLicenseFile,
    LicenseFileRead,
    LicenseRequestDecline,
    LicenseRequestRead,
    LicenseUsage,
    RoleCreate,
    RoleRead,
    RoleUpdate,
    SetPasswordInput,
    TenantCreate,
    TenantLicense,
    TenantLicenseStatus,
    TenantRead,
    TenantUpdate,
    UserCreate,
    UserRead,
    UserRoleRead,
    UserUpdate,
)
from app.schemas.master import MasterRead, MasterRecordCreate, MasterRecordRead, MasterRecordUpdate
from app.schemas.platform_admin import (
    PlatformAdminChangePasswordRequest,
    PlatformAdminLoginRequest,
    PlatformAdminLoginResponse,
    PlatformAdminRead,
)
from app.schemas.workflow import FormRead, FormSubmissionRead, FormSubmissionUpdate

router = APIRouter(prefix="/admin", tags=["platform-admin"])

_INVALID_CREDENTIALS = "Invalid email or password."

user_crud = CRUDBase[User, UserCreate, UserUpdate](User)


# ---- Auth ------------------------------------------------------------------------------------------------

@router.post("/auth/login", response_model=PlatformAdminLoginResponse)
async def admin_login(payload: PlatformAdminLoginRequest) -> PlatformAdminLoginResponse:
    async with AsyncSessionLocal() as db:
        async with db.begin():
            admin = (
                await db.execute(select(PlatformAdmin).where(PlatformAdmin.email == payload.email))
            ).scalar_one_or_none()
            if admin is None or not verify_password(payload.password, admin.password_hash):
                raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_INVALID_CREDENTIALS)
            if admin.status != "active":
                raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This account is inactive.")

            admin.last_login_at = datetime.now(timezone.utc)
            await db.flush()
            await db.refresh(admin)

            token = create_platform_admin_token(admin_id=admin.id)
            return PlatformAdminLoginResponse(token=token, admin=PlatformAdminRead.model_validate(admin))


@router.get("/me", response_model=PlatformAdminRead)
async def get_me(
    ctx: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> PlatformAdmin:
    admin = (await db.execute(select(PlatformAdmin).where(PlatformAdmin.id == ctx.admin_id))).scalar_one_or_none()
    if admin is None:
        raise HTTPException(status_code=404, detail="Admin not found")
    return admin


@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_my_password(
    payload: PlatformAdminChangePasswordRequest,
    ctx: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> None:
    admin = (await db.execute(select(PlatformAdmin).where(PlatformAdmin.id == ctx.admin_id))).scalar_one_or_none()
    if admin is None:
        raise HTTPException(status_code=404, detail="Admin not found")
    if not verify_password(payload.current_password, admin.password_hash):
        raise HTTPException(status_code=401, detail="Current password is incorrect.")
    admin.password_hash = hash_password(payload.new_password)
    await db.flush()


# ---- Tenants -----------------------------------------------------------------------------------------------

@router.get("/tenants", response_model=list[TenantRead])
async def list_tenants(
    _: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> list[Tenant]:
    return list((await db.execute(select(Tenant).order_by(Tenant.name))).scalars().all())


@router.post("/tenants", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
async def create_tenant(
    payload: TenantCreate,
    _: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> Tenant:
    """Also seeds the 6 baseline roles (Administrator — every RBAC module,
    every action — plus Process Owner/Approver/Form Designer/Viewer/Portal),
    the same set scripts/provision_tenant.py's CLI flow already seeds (see
    app/crud/tenant_provisioning.py) — this UI flow just doesn't collect a
    first admin user's details, so it stops short of creating one; a
    platform admin adds that separately via "New user" on this tenant."""
    tenant = Tenant(name=payload.name)
    if payload.timezone:
        tenant.timezone = payload.timezone
    if payload.license is not None:
        _apply_license(tenant, payload.license)
    db.add(tenant)
    await db.flush()
    # The roles are the new tenant's own rows (row-level security, db/schema.sql).
    await db.execute(text("SELECT set_config('app.current_tenant_id', :v, true)"), {"v": str(tenant.id)})
    await seed_baseline_roles(db, tenant.id)
    await db.refresh(tenant)
    return tenant


@router.get("/timezones", response_model=list[str])
async def list_timezones(_: PlatformAdminContext = Depends(get_platform_admin_context)) -> list[str]:
    """Every time zone a tenant can be set to (Tenant › Settings)."""
    return listed_timezones()


@router.patch("/tenants/{tenant_id}", response_model=TenantRead)
async def update_tenant(
    tenant_id: uuid.UUID,
    payload: TenantUpdate,
    _: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> Tenant:
    tenant = (await db.execute(select(Tenant).where(Tenant.id == tenant_id))).scalar_one_or_none()
    if tenant is None:
        raise HTTPException(status_code=404, detail="Tenant not found")
    for field, value in payload.model_dump(exclude_unset=True).items():
        if value is not None or field != "timezone":  # a time zone can't be cleared
            setattr(tenant, field, value)
    await db.flush()
    await db.refresh(tenant)
    return tenant


# ---- Licenses (app/crud/licensing.py) ---------------------------------------------------------------------

def _apply_license(tenant: Tenant, license_: TenantLicense) -> None:
    tenant.license_set_at = tenant.license_set_at or datetime.now(timezone.utc)
    tenant.license_max_users = license_.max_users
    tenant.license_max_portal_users = license_.max_portal_users
    tenant.license_starts_on = license_.starts_on
    tenant.license_ends_on = license_.ends_on


@router.get("/license-usage", response_model=dict[uuid.UUID, LicenseUsage])
async def license_usage(
    _: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> dict[uuid.UUID, LicenseUsage]:
    """Active accounts per tenant, for the tenant list. Each count runs
    scoped to its own tenant (row-level security, db/schema.sql)."""
    result = {}
    for tenant_id in (await db.execute(select(Tenant.id))).scalars().all():
        await db.execute(text("SELECT set_config('app.current_tenant_id', :v, true)"), {"v": str(tenant_id)})
        result[tenant_id] = await licensing.usage(db, tenant_id)
    return result


@router.get("/tenants/{tenant_id}/password-policy", response_model=password_policy.PasswordPolicyRead)
async def get_tenant_password_policy(tenant_id: uuid.UUID, db: AsyncSession = Depends(get_admin_scoped_db)) -> password_policy.PasswordPolicyRead:
    tenant = await db.get(Tenant, tenant_id)
    if tenant is None:
        raise HTTPException(status_code=404, detail="Tenant not found")
    rules = password_policy.policy_of(tenant)
    return password_policy.PasswordPolicyRead(**rules.model_dump(), requirements=password_policy.requirements(rules))


@router.put("/tenants/{tenant_id}/password-policy", response_model=password_policy.PasswordPolicyRead)
async def update_tenant_password_policy(
    tenant_id: uuid.UUID,
    payload: password_policy.PasswordPolicy,
    ctx: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> password_policy.PasswordPolicyRead:
    """Sets the workspace's password rules (app/crud/password_policy.py).
    They apply to every password set from now on; existing passwords are
    asked to change only by expiry. Recorded in the workspace's audit log."""
    tenant = await db.get(Tenant, tenant_id)
    if tenant is None:
        raise HTTPException(status_code=404, detail="Tenant not found")
    before = password_policy.policy_of(tenant).model_dump(mode="json")
    tenant.password_policy = payload.model_dump(mode="json")
    await db.flush()
    await write_audit_log(
        db, tenant_id, None, "tenant.password_policy_updated", "tenant", tenant_id,
        {"before": before, "after": tenant.password_policy, "platform_admin_id": str(ctx.admin_id)},
    )
    return password_policy.PasswordPolicyRead(**payload.model_dump(), requirements=password_policy.requirements(payload))


@router.get("/tenants/{tenant_id}/license", response_model=TenantLicenseStatus)
async def get_tenant_license(tenant_id: uuid.UUID, db: AsyncSession = Depends(get_admin_scoped_db)) -> TenantLicenseStatus:
    tenant = await db.get(Tenant, tenant_id)
    if tenant is None:
        raise HTTPException(status_code=404, detail="Tenant not found")
    return await licensing.license_status(db, tenant)


@router.put("/tenants/{tenant_id}/license", response_model=TenantLicenseStatus)
async def update_tenant_license(
    tenant_id: uuid.UUID,
    payload: TenantLicense,
    ctx: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> TenantLicenseStatus:
    """Licenses the tenant with these terms (blank = no limit), replacing
    any it had. A limit below what's in use is allowed: those accounts keep
    working, and no more can be activated until it's back under. Recorded
    in the tenant's own audit log."""
    tenant = await db.get(Tenant, tenant_id)
    if tenant is None:
        raise HTTPException(status_code=404, detail="Tenant not found")
    before = TenantLicense.model_validate(tenant.license).model_dump(mode="json")
    _apply_license(tenant, payload)
    await db.flush()
    licensing.forget(tenant_id)
    await write_audit_log(
        db, tenant_id, None, "tenant.license_updated", "tenant", tenant_id,
        {"before": before, "after": payload.model_dump(mode="json"), "platform_admin_id": str(ctx.admin_id)},
    )
    return await licensing.license_status(db, tenant)


@router.delete("/tenants/{tenant_id}/license", response_model=TenantLicenseStatus)
async def remove_tenant_license(
    tenant_id: uuid.UUID,
    ctx: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> TenantLicenseStatus:
    """Takes the tenant's license away: nobody can sign in to it until it's
    licensed again (here, or by applying a license file)."""
    tenant = await db.get(Tenant, tenant_id)
    if tenant is None:
        raise HTTPException(status_code=404, detail="Tenant not found")
    before = TenantLicense.model_validate(tenant.license).model_dump(mode="json")
    _apply_license(tenant, TenantLicense())
    tenant.license_set_at = None
    await db.flush()
    licensing.forget(tenant_id)
    await write_audit_log(
        db, tenant_id, None, "tenant.license_removed", "tenant", tenant_id, {"before": before, "platform_admin_id": str(ctx.admin_id)}
    )
    return await licensing.license_status(db, tenant)


# ---- Platform settings (app/core/platform_settings.py) -----------------------------------------------------


class PlatformSettingRead(BaseModel):
    key: str
    label: str
    group: str
    description: str
    kind: str
    choices: list[str]
    editable: bool
    restart: bool
    secret: bool
    minimum: int | None = None
    maximum: int | None = None
    # In force now; never sent for a secret (has_value says whether there is one).
    value: str | None
    has_value: bool
    # "override": set here. "env": from .env. "default": built in.
    source: str
    # What it falls back to when cleared here.
    base_value: str | None
    base_has_value: bool
    updated_at: datetime | None = None
    updated_by: str | None = None


class PlatformSettingWrite(BaseModel):
    value: str = Field(max_length=4000)


@router.get("/platform-settings", response_model=list[PlatformSettingRead])
async def list_platform_settings(
    _: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> list[dict]:
    return await platform_settings.describe(db)


@router.put("/platform-settings/{key}", response_model=list[PlatformSettingRead])
async def set_platform_setting(
    key: str,
    payload: PlatformSettingWrite,
    ctx: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> list[dict]:
    """Overrides .env for the whole deployment. Every process picks it up
    within half a minute; a setting marked `restart` after a restart."""
    try:
        await platform_settings.set_value(db, key, payload.value, ctx.admin_id)
    except platform_settings.InvalidSetting as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from None
    return await platform_settings.describe(db)


@router.delete("/platform-settings/{key}", response_model=list[PlatformSettingRead])
async def clear_platform_setting(
    key: str,
    _: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> list[dict]:
    """Back to the .env value (or the built-in default)."""
    try:
        await platform_settings.clear(db, key)
    except platform_settings.InvalidSetting as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from None
    return await platform_settings.describe(db)


# ---- License files (app/crud/license_files.py) -------------------------------------------------------------

async def _tenant_or_404(db: AsyncSession, tenant_id: uuid.UUID) -> Tenant:
    tenant = await db.get(Tenant, tenant_id)
    if tenant is None:
        raise HTTPException(status_code=404, detail="Tenant not found")
    return tenant


@router.get("/tenants/{tenant_id}/license-files", response_model=list[LicenseFileRead])
async def list_license_files(tenant_id: uuid.UUID, db: AsyncSession = Depends(get_admin_scoped_db)) -> list[LicenseFileRead]:
    await _tenant_or_404(db, tenant_id)
    return await license_files.history(db, tenant_id)


@router.post("/tenants/{tenant_id}/license-files", response_model=IssuedLicenseFile, status_code=status.HTTP_201_CREATED)
async def issue_license_file(
    tenant_id: uuid.UUID,
    payload: TenantLicense,
    request_id: uuid.UUID | None = None,
    ctx: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> IssuedLicenseFile:
    """A license file granting these terms, for the tenant's administrator
    to apply. The tenant's license doesn't change until they do. With
    `request_id`, it answers that renewal request, and the tenant can apply
    it from the Studio without the file."""
    tenant = await _tenant_or_404(db, tenant_id)
    try:
        file, content = await license_files.issue(db, tenant, payload, ctx.admin_id)
        if request_id is not None:
            await license_requests.fulfil(db, tenant_id, request_id, file, ctx.admin_id)
    except license_files.LicenseFileError as exc:
        raise license_files.http_error(exc) from None
    return IssuedLicenseFile(file=LicenseFileRead.model_validate(file), filename=license_files.filename_for(tenant, file), content=content)


@router.get("/license-requests", response_model=list[LicenseRequestRead])
async def list_all_license_requests(
    open_only: bool = True,
    _: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> list[LicenseRequestRead]:
    """Renewal requests across every tenant, newest first — open ones only
    unless `open_only=false`. Each tenant's are read scoped to it."""
    result: list[LicenseRequestRead] = []
    for tenant_id, name in (await db.execute(select(Tenant.id, Tenant.name))).all():
        await db.execute(text("SELECT set_config('app.current_tenant_id', :v, true)"), {"v": str(tenant_id)})
        result += await license_requests.history(db, tenant_id, tenant_name=name, open_only=open_only)
    return sorted(result, key=lambda r: r.created_at, reverse=True)


@router.get("/tenants/{tenant_id}/license-requests", response_model=list[LicenseRequestRead])
async def list_tenant_license_requests(tenant_id: uuid.UUID, db: AsyncSession = Depends(get_admin_scoped_db)) -> list[LicenseRequestRead]:
    tenant = await _tenant_or_404(db, tenant_id)
    return await license_requests.history(db, tenant_id, tenant_name=tenant.name)


@router.post("/tenants/{tenant_id}/license-requests/{request_id}/decline", status_code=status.HTTP_204_NO_CONTENT)
async def decline_license_request(
    tenant_id: uuid.UUID,
    request_id: uuid.UUID,
    payload: LicenseRequestDecline,
    ctx: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> None:
    try:
        await license_requests.decline(db, tenant_id, request_id, ctx.admin_id, payload.response)
    except license_files.LicenseFileError as exc:
        raise license_files.http_error(exc) from None


@router.get("/tenants/{tenant_id}/license-files/{file_id}/download", response_model=IssuedLicenseFile)
async def download_license_file(tenant_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_admin_scoped_db)) -> IssuedLicenseFile:
    tenant = await _tenant_or_404(db, tenant_id)
    read = next((f for f in await license_files.history(db, tenant_id) if f.id == file_id), None)
    if read is None:
        raise HTTPException(status_code=404, detail="License file not found.")
    file = await db.get(LicenseFile, file_id)
    return IssuedLicenseFile(file=read, filename=license_files.filename_for(tenant, file), content=license_files.content_for(tenant, file))


@router.post("/tenants/{tenant_id}/license-files/{file_id}/revoke", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_license_file(tenant_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_admin_scoped_db)) -> None:
    """Withdraws a file that hasn't been applied — it can't be applied after."""
    try:
        await license_files.revoke(db, tenant_id, file_id)
    except license_files.LicenseFileError as exc:
        raise license_files.http_error(exc) from None


# Every tenant-scoped table (db/schema.sql), children before parents so no
# FK blocks the delete. Kept as an explicit list rather than derived at
# runtime so it's obvious (and reviewable) exactly what a tenant hard-delete
# touches — update this if a new tenant-scoped table is ever added.
_TENANT_SCOPED_TABLES_CHILDREN_FIRST = [
    "summary_cache", "form_submission_reviews",
    "saved_views", "user_sessions", "license_requests", "license_files", "components", "workspaces", "payment_transactions",
    "apps", "releases", "uploaded_files", "notifications", "ai_assistant_sessions",
    "audit_log_entries", "entity_migrations", "entity_tables", "datasets", "secrets", "record_access_policies", "page_versions", "app_import_links", "data_queries", "report_templates", "connectors", "reports", "sequence_counters",
    "scheduled_jobs", "tasks", "pages", "widgets", "dashboards", "menus",
    "form_submissions", "step_history", "workflow_instances", "forms", "workflows",
    # Users before roles: a role someone still holds can't be deleted
    # (user_roles.role_id is ON DELETE RESTRICT); deleting the user drops
    # their user_roles rows with it.
    "user_scope_assignments", "master_records", "masters", "users", "roles", "vendors",
]


@router.delete("/tenants/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tenant(
    tenant_id: uuid.UUID,
    _: PlatformAdminContext = Depends(get_platform_admin_context),
    db: AsyncSession = Depends(get_unscoped_db),
) -> None:
    """Hard delete — irreversible. Sets the RLS tenant GUC to the target
    tenant for this one transaction (satisfies every tenant-scoped table's
    policy) then deletes each tenant-scoped table's rows for it, children
    first, before finally deleting the tenant row itself (which carries no
    RLS policy of its own)."""
    tenant = (await db.execute(select(Tenant).where(Tenant.id == tenant_id))).scalar_one_or_none()
    if tenant is None:
        raise HTTPException(status_code=404, detail="Tenant not found")

    await db.execute(text("SELECT set_config('app.current_tenant_id', :v, true)"), {"v": str(tenant_id)})
    # Datasources stored as tables: their tables and sync triggers go first.
    await entity_storage.drop_tenant_storage(db, tenant_id)
    for table in _TENANT_SCOPED_TABLES_CHILDREN_FIRST:
        await db.execute(text(f"DELETE FROM {table} WHERE tenant_id = :tenant_id"), {"tenant_id": str(tenant_id)})
    await db.delete(tenant)
    await db.flush()


# ---- Users (within one tenant) ----------------------------------------------------------------------------

@router.get("/tenants/{tenant_id}/users", response_model=list[UserRead])
async def list_tenant_users(
    tenant_id: uuid.UUID,
    skip: int = 0,
    limit: int = 100,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> list[User]:
    return await user_crud.list(db, tenant_id, skip=skip, limit=limit)


@router.post("/tenants/{tenant_id}/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_tenant_user(
    tenant_id: uuid.UUID,
    payload: UserCreate,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> User:
    if payload.status == "active":
        await licensing.ensure_seat(db, tenant_id, portal=payload.vendor_id is not None)
    data = payload.model_dump(exclude={"password"}, exclude_unset=True, mode="json")
    data["tenant_id"] = tenant_id
    user = User(**data)
    password_policy.set_password(await db.get(Tenant, tenant_id), user, payload.password, by_admin=True)
    db.add(user)
    await db.flush()
    await db.refresh(user)
    return user


@router.patch("/tenants/{tenant_id}/users/{user_id}", response_model=UserRead)
async def update_tenant_user(
    tenant_id: uuid.UUID,
    user_id: uuid.UUID,
    payload: UserUpdate,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> User:
    if payload.status == "active":
        current = await user_crud.get(db, tenant_id, user_id)
        if current.status != "active":
            await licensing.ensure_seat(db, tenant_id, portal=current.vendor_id is not None)
    updated = await user_crud.update(db, tenant_id, user_id, payload)
    if payload.status == "inactive":
        await sessions.sign_out_everywhere(db, updated)
    return updated


@router.post("/tenants/{tenant_id}/users/{user_id}/set-password", status_code=status.HTTP_204_NO_CONTENT)
async def set_tenant_user_password(
    tenant_id: uuid.UUID,
    user_id: uuid.UUID,
    payload: SetPasswordInput,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> None:
    user = await user_crud.get(db, tenant_id, user_id)
    password_policy.set_password(await db.get(Tenant, tenant_id), user, payload.password, by_admin=True)
    account_emails.clear_reset(user)
    await sessions.sign_out_everywhere(db, user)
    await db.flush()
    await account_emails.queue(db, await db.get(Tenant, tenant_id), "password_changed", user)


@router.delete("/tenants/{tenant_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tenant_user(
    tenant_id: uuid.UUID,
    user_id: uuid.UUID,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> None:
    await user_crud.delete(db, tenant_id, user_id)


# ---- Roles (within one tenant) ------------------------------------------------------------------------------
#
# So a Super Admin can actually make a newly-created user useful — assign
# it the tenant's Administrator role (or any other internal/portal role) —
# without that requiring a first tenant-side admin to already exist and
# sign in. Mirrors routers/roles.py::list_roles and routers/users.py's own
# role-assignment endpoints, minus the users_roles RBAC gate (not
# applicable — a platform admin's authority isn't tenant RBAC) and minus
# the "last active Administrator" guard those keep (a platform admin can
# always fix a tenant that guard would otherwise lock out a regular admin
# from).

@router.get("/tenants/{tenant_id}/roles", response_model=list[RoleRead])
async def list_tenant_roles(
    tenant_id: uuid.UUID,
    scope: str | None = None,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> list[Role]:
    return await role_crud.list(db, tenant_id, limit=500, scope=scope)


@router.post("/tenants/{tenant_id}/roles", response_model=RoleRead, status_code=status.HTTP_201_CREATED)
async def create_tenant_role(
    tenant_id: uuid.UUID,
    payload: RoleCreate,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> Role:
    return await role_crud.create(db, tenant_id, payload)


@router.patch("/tenants/{tenant_id}/roles/{role_id}", response_model=RoleRead)
async def update_tenant_role(
    tenant_id: uuid.UUID,
    role_id: uuid.UUID,
    payload: RoleUpdate,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> Role:
    role = await role_crud.get(db, tenant_id, role_id)
    if role.is_system_role and payload.action_matrix is not None:
        raise HTTPException(
            status_code=409,
            detail="The Administrator role's permissions are fixed and cannot be reduced (FR-RBA-005).",
        )
    return await role_crud.update(db, tenant_id, role_id, payload)


@router.delete("/tenants/{tenant_id}/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tenant_role(
    tenant_id: uuid.UUID,
    role_id: uuid.UUID,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> None:
    role = await role_crud.get(db, tenant_id, role_id)
    if role.is_system_role:
        raise HTTPException(status_code=409, detail="The Administrator role cannot be deleted (FR-RBA-005).")
    await role_crud.delete(db, tenant_id, role_id)


@router.get("/tenants/{tenant_id}/users/{user_id}/roles", response_model=list[RoleRead])
async def list_tenant_user_roles(
    tenant_id: uuid.UUID,
    user_id: uuid.UUID,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> list[Role]:
    await user_crud.get(db, tenant_id, user_id)  # 404s if user isn't in this tenant
    stmt = select(Role).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id)
    return list((await db.execute(stmt)).scalars().all())


@router.put(
    "/tenants/{tenant_id}/users/{user_id}/roles/{role_id}", response_model=UserRoleRead, status_code=status.HTTP_201_CREATED
)
async def assign_tenant_user_role(
    tenant_id: uuid.UUID,
    user_id: uuid.UUID,
    role_id: uuid.UUID,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> UserRole:
    user = await user_crud.get(db, tenant_id, user_id)
    role = (await db.execute(select(Role).where(Role.id == role_id, Role.tenant_id == tenant_id))).scalar_one_or_none()
    if role is None:
        raise HTTPException(status_code=404, detail="Role not found")
    is_portal_role = role.scope == "portal"
    if bool(user.vendor_id) != is_portal_role:
        raise HTTPException(
            status_code=422,
            detail="Portal accounts can only hold portal-scope roles, and internal accounts can only hold internal-scope roles.",
        )

    existing = (
        await db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role_id))
    ).scalar_one_or_none()
    if existing is not None:
        return existing

    link = UserRole(user_id=user_id, role_id=role_id)
    db.add(link)
    await db.flush()
    return link


@router.delete("/tenants/{tenant_id}/users/{user_id}/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def unassign_tenant_user_role(
    tenant_id: uuid.UUID,
    user_id: uuid.UUID,
    role_id: uuid.UUID,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> None:
    link = (
        await db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role_id))
    ).scalar_one_or_none()
    if link is None:
        raise HTTPException(status_code=404, detail="User does not have this role")
    await db.delete(link)
    await db.flush()


# ---- Data Explorer: Masters + records (within one tenant) --------------------------------------------------

@router.get("/tenants/{tenant_id}/masters", response_model=list[MasterRead])
async def list_tenant_masters(
    tenant_id: uuid.UUID,
    skip: int = 0,
    limit: int = 100,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> list[Master]:
    return await master_crud.list(db, tenant_id, skip=skip, limit=limit)


@router.get("/tenants/{tenant_id}/masters/{master_id}/records", response_model=list[MasterRecordRead])
async def list_tenant_master_records(
    tenant_id: uuid.UUID,
    master_id: uuid.UUID,
    skip: int = 0,
    limit: int = 100,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> list[MasterRecord]:
    await master_crud.get(db, tenant_id, master_id)  # 404s if it isn't in this tenant
    return await records_crud.list(db, tenant_id, skip=skip, limit=limit, master_id=master_id, is_deleted=False)


@router.post(
    "/tenants/{tenant_id}/masters/{master_id}/records",
    response_model=MasterRecordRead,
    status_code=status.HTTP_201_CREATED,
)
async def create_tenant_master_record(
    tenant_id: uuid.UUID,
    master_id: uuid.UUID,
    payload: MasterRecordCreate,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> MasterRecord:
    master = await master_crud.get(db, tenant_id, master_id)
    payload.field_values = await field_constraints.prepare_create(db, tenant_id, None, master, payload.field_values)
    errors = await validate_field_values(db, tenant_id, master.field_schema, payload.field_values)
    errors += await field_constraints.unique_errors(db, tenant_id, master, payload.field_values)
    if errors:
        raise HTTPException(status_code=422, detail=errors)
    payload.field_values = await field_constraints.finish_create(db, tenant_id, master, {
        **payload.field_values,
        **compute_formula_fields(master.field_schema, payload.field_values),
    })
    return await records_crud.create(db, tenant_id, payload, master_id=master_id)


@router.patch("/tenants/{tenant_id}/masters/{master_id}/records/{record_id}", response_model=MasterRecordRead)
async def update_tenant_master_record(
    tenant_id: uuid.UUID,
    master_id: uuid.UUID,
    record_id: uuid.UUID,
    payload: MasterRecordUpdate,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> MasterRecord:
    master = await master_crud.get(db, tenant_id, master_id)
    existing = await records_crud.get(db, tenant_id, record_id)
    if existing.master_id != master_id or existing.is_deleted:
        raise HTTPException(status_code=404, detail="MasterRecord not found")
    payload.field_values = field_constraints.keep_autonumbers(master, payload.field_values, existing.field_values)
    errors = await validate_field_values(db, tenant_id, master.field_schema, payload.field_values)
    errors += await field_constraints.unique_errors(db, tenant_id, master, payload.field_values, record_id=record_id)
    if errors:
        raise HTTPException(status_code=422, detail=errors)
    payload.field_values = {
        **payload.field_values,
        **compute_formula_fields(master.field_schema, payload.field_values),
    }
    return await records_crud.update(db, tenant_id, record_id, payload)


@router.delete("/tenants/{tenant_id}/masters/{master_id}/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tenant_master_record(
    tenant_id: uuid.UUID,
    master_id: uuid.UUID,
    record_id: uuid.UUID,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> None:
    existing = await records_crud.get(db, tenant_id, record_id)
    if existing.master_id != master_id or existing.is_deleted:
        raise HTTPException(status_code=404, detail="MasterRecord not found")
    # Soft delete — same as every other record delete in the app (see
    # routers/masters.py::delete_master_record) so Super Admin actions
    # aren't more destructive than the feature they're mirroring.
    await records_crud.soft_delete(db, tenant_id, record_id)


# ---- Data Explorer: Forms + submissions (within one tenant) ------------------------------------------------
#
# No create endpoint here, deliberately — a submission represents someone
# having filled out a form; a Super Admin editing/removing an existing one
# is "Data Explorer" in spirit, but fabricating a brand new submission isn't
# the same kind of action as adding a Master record, so it's left out.

async def _get_form_version(db: AsyncSession, tenant_id: uuid.UUID, form_id: uuid.UUID, version: int) -> Form:
    form = (
        await db.execute(
            select(Form).where(Form.tenant_id == tenant_id, Form.form_id == form_id, Form.version == version)
        )
    ).scalar_one_or_none()
    if form is None:
        raise HTTPException(status_code=404, detail="Form version not found")
    return form


@router.get("/tenants/{tenant_id}/forms", response_model=list[FormRead])
async def list_tenant_forms(
    tenant_id: uuid.UUID,
    skip: int = 0,
    limit: int = 100,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> list[Form]:
    stmt = (
        select(Form)
        .where(Form.tenant_id == tenant_id, Form.is_current.is_(True))
        .order_by(Form.name)
        .offset(skip)
        .limit(limit)
    )
    return list((await db.execute(stmt)).scalars().all())


@router.get("/tenants/{tenant_id}/forms/{form_id}/submissions", response_model=list[FormSubmissionRead])
async def list_tenant_form_submissions(
    tenant_id: uuid.UUID,
    form_id: uuid.UUID,
    skip: int = 0,
    limit: int = 100,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> list[FormSubmission]:
    return await form_submissions_crud.list(
        db, tenant_id, skip=skip, limit=limit, form_id=form_id, is_deleted=False
    )


@router.patch(
    "/tenants/{tenant_id}/forms/{form_id}/submissions/{submission_id}", response_model=FormSubmissionRead
)
async def update_tenant_form_submission(
    tenant_id: uuid.UUID,
    form_id: uuid.UUID,
    submission_id: uuid.UUID,
    payload: FormSubmissionUpdate,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> FormSubmission:
    existing = await form_submissions_crud.get(db, tenant_id, submission_id)
    if existing.form_id != form_id:
        raise HTTPException(status_code=404, detail="FormSubmission not found")
    # Pinned to the exact version this submission was made against, not
    # whatever the form's current published version is — see
    # routers/form_submissions.py::create_form_submission for the same rule.
    form = await _get_form_version(db, tenant_id, form_id, existing.form_version)

    # Same value integrity as a tenant-side edit (types, options, linked
    # records, files) — app/crud/form_integrity.py. A Super Admin isn't a
    # tenant user, so no field-access rules apply.
    from app.crud.form_integrity import check_submission_values

    payload.field_values = await check_submission_values(
        db, tenant_id, None, form.field_definitions, payload.field_values, previous=dict(existing.field_values)
    )
    # Same required-field / formula-field rule as a tenant-side submission
    # (FR-FRM-008) — see app/core/form_validation.py::missing_required_fields.
    missing = missing_required_fields(form.field_definitions, payload.field_values)
    if missing:
        raise HTTPException(status_code=422, detail={"missing_required_fields": missing})
    # The form's own field validation rules, same as a tenant-side edit.
    rule_errors = validate_field_values(form.field_definitions, payload.field_values)
    if rule_errors:
        raise HTTPException(status_code=422, detail=field_errors_detail(form.field_definitions, rule_errors))

    field_values = dict(payload.field_values)
    for field in form.field_definitions:
        if field.get("type") == "formula":
            field_values.pop(field["name"], None)
    payload.field_values = {**field_values, **compute_formula_fields(form.field_definitions, field_values)}

    return await form_submissions_crud.update(db, tenant_id, submission_id, payload)


@router.delete(
    "/tenants/{tenant_id}/forms/{form_id}/submissions/{submission_id}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_tenant_form_submission(
    tenant_id: uuid.UUID,
    form_id: uuid.UUID,
    submission_id: uuid.UUID,
    db: AsyncSession = Depends(get_admin_scoped_db),
) -> None:
    existing = await form_submissions_crud.get(db, tenant_id, submission_id)
    if existing.form_id != form_id:
        raise HTTPException(status_code=404, detail="FormSubmission not found")
    await form_submissions_crud.soft_delete(db, tenant_id, submission_id)
