import logging
from uuid import UUID

from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import JSONResponse

from paskia import aaguid as aaguid_mod
from paskia import db
from paskia.authsession import reset_expires
from paskia.db import Org as OrgDC
from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC
from paskia.db import User as UserDC
from paskia.db.operations import _UNSET
from paskia.db.structs import Client
from paskia.fastapi import authz
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
from paskia.util import (
    hostutil,
    permutil,
    querysafe,
    vitedev,
)
from paskia.util.apistructs import (
    ApiAaguidInfo,
    ApiAdminInfo,
    ApiCreateLinkResponse,
    ApiOidcClient,
    ApiOrg,
    ApiOrgResponse,
    ApiPermission,
    ApiRole,
    ApiUser,
    ApiUserDetail,
    ApiUserSession,
    ApiUuidResponse,
)
from paskia.util.hostutil import normalize_host

app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)


def master_admin(ctx) -> bool:
    return any(p.scope == "auth:admin" for p in ctx.permissions)


def org_admin(ctx, org_uuid: UUID) -> bool:
    return ctx.org.uuid == org_uuid and any(
        p.scope == "auth:org:admin" for p in ctx.permissions
    )


def can_manage_org(ctx, org_uuid: UUID) -> bool:
    return master_admin(ctx) or org_admin(ctx, org_uuid)


@app.exception_handler(ValueError)
async def value_error_handler(_request, exc: ValueError):  # pragma: no cover - simple
    return JSONResponse(status_code=400, content={"detail": str(exc)})


@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException):
    """Handle AuthException with auth info for UI."""
    return JSONResponse(
        status_code=exc.status_code,
        content=await authz.auth_error_content(exc),
    )


@app.exception_handler(Exception)
async def general_exception_handler(_request, exc: Exception):  # pragma: no cover
    logging.exception("Unhandled exception in admin app")
    return JSONResponse(status_code=500, content={"detail": "Internal server error"})


@app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE):
    return await vitedev.handle(request, frontend, "/auth/admin/")


# -------------------- Organizations --------------------


@app.post("/orgs")
async def admin_create_org(
    request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
    ctx = await authz.verify(
        auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
    )

    display_name = payload.get("display_name") or "New Organization"
    permissions = payload.get("permissions") or []
    org = OrgDC.create(display_name=display_name)
    db.create_org(org, ctx=ctx)
    # Grant requested permissions to the new org
    for perm in permissions:
        db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)

    return MsgspecResponse(ApiUuidResponse(uuid=str(org.uuid)))


@app.patch("/orgs/{org_uuid}")
async def admin_update_org_name(
    org_uuid: UUID,
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    """Update organization display name only."""
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, org_uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )
    display_name = payload.get("display_name")
    if not display_name:
        raise ValueError("display_name is required")

    db.update_org_name(org_uuid, display_name, ctx=ctx)
    return {"status": "ok"}


@app.delete("/orgs/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
        max_age="5m",
    )
    if not can_manage_org(ctx, org_uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )
    if ctx.org.uuid == org_uuid:
        raise ValueError("Cannot delete the organization you belong to")

    # Delete organization-specific permissions
    org_perm_pattern = f"org:{str(org_uuid).lower()}"
    all_permissions = list(db.data().permissions.values())
    for perm in all_permissions:
        perm_scope_lower = perm.scope.lower()
        # Check if permission contains "org:{uuid}" separated by colons or at boundaries
        if (
            f":{org_perm_pattern}:" in perm_scope_lower
            or perm_scope_lower.startswith(f"{org_perm_pattern}:")
            or perm_scope_lower.endswith(f":{org_perm_pattern}")
            or perm_scope_lower == org_perm_pattern
        ):
            db.delete_permission(perm.uuid, ctx=ctx)

    db.delete_org(org_uuid, ctx=ctx)
    return {"status": "ok"}


@app.post("/orgs/{org_uuid}/permission")
async def admin_add_org_permission(
    org_uuid: UUID,
    request: Request,
    permission_uuid: UUID = Query(...),
    auth=AUTH_COOKIE,
):
    ctx = await authz.verify(
        auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
    )

    db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
    return {"status": "ok"}


@app.delete("/orgs/{org_uuid}/permission")
async def admin_remove_org_permission(
    org_uuid: UUID,
    request: Request,
    permission_uuid: UUID = Query(...),
    auth=AUTH_COOKIE,
):
    ctx = await authz.verify(
        auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
    )

    db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)

    # Guard rail: prevent removing auth:admin from your own org if it would lock you out
    perm = db.data().permissions.get(permission_uuid)
    if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
        # Check if any other org grants auth:admin that we're a member of
        # (we only know our current org, so this effectively means we can't remove it from our own org)
        raise ValueError(
            "Cannot remove auth:admin from your own organization. "
            "This would lock you out of admin access."
        )

    db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
    return {"status": "ok"}


# -------------------- Roles --------------------


@app.post("/orgs/{org_uuid}/roles")
async def admin_create_role(
    org_uuid: UUID,
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, org_uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )

    display_name = payload.get("display_name") or "New Role"
    perms = payload.get("permissions") or []
    if org_uuid not in db.data().orgs:
        raise HTTPException(status_code=404, detail="Organization not found")
    org = db.data().orgs[org_uuid]
    grantable = {p.uuid for p in org.permissions}

    # Normalize permission IDs to UUIDs
    permission_uuids: set[UUID] = set()
    for pid in perms:
        perm = db.data().permissions.get(UUID(pid))
        if not perm:
            raise ValueError(f"Permission {pid} not found")
        if perm.uuid not in grantable:
            raise ValueError(f"Permission not grantable by org: {pid}")
        permission_uuids.add(perm.uuid)

    role = RoleDC.create(
        org=org_uuid,
        display_name=display_name,
        permissions=permission_uuids,
    )
    db.create_role(role, ctx=ctx)
    return MsgspecResponse(ApiUuidResponse(uuid=str(role.uuid)))


@app.patch("/roles/{role_uuid}")
async def admin_update_role_name(
    role_uuid: UUID,
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    """Update role display name only."""
    role = db.data().roles.get(role_uuid)
    if not role:
        raise HTTPException(status_code=404, detail="Role not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, role.org_uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )

    display_name = payload.get("display_name")
    if not display_name:
        raise ValueError("display_name is required")

    db.update_role_name(role_uuid, display_name, ctx=ctx)
    return {"status": "ok"}


@app.post("/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_add_role_permission(
    role_uuid: UUID,
    permission_uuid: UUID,
    request: Request,
    auth=AUTH_COOKIE,
):
    """Add a permission to a role (intent-based API)."""
    role = db.data().roles.get(role_uuid)
    if not role:
        raise HTTPException(status_code=404, detail="Role not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, role.org_uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )

    # Verify permission exists and org can grant it
    perm = db.data().permissions.get(permission_uuid)
    if not perm:
        raise HTTPException(status_code=404, detail="Permission not found")
    if role.org_uuid not in perm.orgs:
        raise ValueError("Permission not grantable by organization")

    db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
    return {"status": "ok"}


@app.delete("/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_remove_role_permission(
    role_uuid: UUID,
    permission_uuid: UUID,
    request: Request,
    auth=AUTH_COOKIE,
):
    """Remove a permission from a role (intent-based API)."""
    role = db.data().roles.get(role_uuid)
    if not role:
        raise HTTPException(status_code=404, detail="Role not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, role.org_uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )

    # Sanity check: prevent admin from removing their own access
    perm = db.data().permissions.get(permission_uuid)
    if ctx.org.uuid == role.org_uuid and ctx.role.uuid == role_uuid:
        if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
            # Check if removing this permission would leave no admin access
            remaining_perms = role.permission_set - {permission_uuid}
            has_admin = False
            for rp_uuid in remaining_perms:
                rp = db.data().permissions.get(rp_uuid)
                if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
                    has_admin = True
                    break
            if not has_admin:
                raise ValueError("Cannot remove your own admin permissions")

    db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
    return {"status": "ok"}


@app.delete("/roles/{role_uuid}")
async def admin_delete_role(
    role_uuid: UUID,
    request: Request,
    auth=AUTH_COOKIE,
):
    role = db.data().roles.get(role_uuid)
    if not role:
        raise HTTPException(status_code=404, detail="Role not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
        max_age="5m",
    )
    if not can_manage_org(ctx, role.org_uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )

    # Sanity check: prevent admin from deleting their own role
    if ctx.role.uuid == role_uuid:
        raise ValueError("Cannot delete your own role")

    db.delete_role(role_uuid, ctx=ctx)
    return {"status": "ok"}


# -------------------- Users --------------------


@app.post("/orgs/{org_uuid}/users")
async def admin_create_user(
    org_uuid: UUID,
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, org_uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )
    display_name = payload.get("display_name")
    role_name = payload.get("role")
    if not display_name or not role_name:
        raise ValueError("display_name and role are required")

    org = db.data().orgs[org_uuid]
    role_obj = next(
        (r for r in org.roles if r.display_name == role_name),
        None,
    )
    if not role_obj:
        raise ValueError("Role not found in organization")
    user = UserDC.create(
        display_name=display_name,
        role=role_obj.uuid,
    )
    db.create_user(user, ctx=ctx)
    return MsgspecResponse(ApiUuidResponse(uuid=str(user.uuid)))


@app.patch("/users/{user_uuid}/role")
async def admin_update_user_role(
    user_uuid: UUID,
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    try:
        user = db.data().users[user_uuid]
    except KeyError:
        raise HTTPException(status_code=404, detail="User not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, user.org.uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )
    role_uuid_str = payload.get("role_uuid")
    if not role_uuid_str:
        raise ValueError("role_uuid is required")
    try:
        new_role_uuid = UUID(role_uuid_str)
    except (ValueError, TypeError):
        raise ValueError("Invalid role UUID")
    new_role = db.data().roles.get(new_role_uuid)
    if not new_role or new_role.org_uuid != user.org.uuid:
        raise ValueError("Role not found in organization")

    # Sanity check: prevent admin from removing their own access
    if ctx.user.uuid == user_uuid:
        # Check if any permission in the new role is an admin permission
        has_admin_access = False
        for perm_uuid in new_role.permissions:
            perm = db.data().permissions.get(perm_uuid)
            if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
                has_admin_access = True
                break
        if not has_admin_access:
            raise ValueError(
                "Cannot change your own role to one without admin permissions"
            )

    db.update_user_role(user_uuid, new_role_uuid, ctx=ctx)
    return {"status": "ok"}


@app.post("/users/{user_uuid}/create-link")
async def admin_create_user_registration_link(
    user_uuid: UUID,
    request: Request,
    auth=AUTH_COOKIE,
):
    try:
        user = db.data().users[user_uuid]
    except KeyError:
        raise HTTPException(status_code=404, detail="User not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
        max_age="5m",
    )
    if not can_manage_org(ctx, user.org.uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )

    # Check if user has existing credentials
    has_credentials = db.data().users[user_uuid].credential_ids
    token_type = "user registration" if not has_credentials else "account recovery"

    expiry = reset_expires()
    token = db.create_reset_token(
        user_uuid=user_uuid,
        expiry=expiry,
        token_type=token_type,
        ctx=ctx,
    )
    url = hostutil.reset_link_url(token)
    return MsgspecResponse(
        ApiCreateLinkResponse(
            url=url,
            expires=expiry,
            token_type=token_type,
        )
    )


@app.get("/users/{user_uuid}")
async def admin_get_user_detail(
    user_uuid: UUID,
    request: Request,
    auth=AUTH_COOKIE,
):
    try:
        user = db.data().users[user_uuid]
    except KeyError:
        raise HTTPException(status_code=404, detail="User not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, user.org.uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )
    normalized_host = hostutil.normalize_host(request.headers.get("host"))

    sessions = {
        s.key: ApiUserSession.from_db(
            s,
            current_key=ctx.session.key,
            normalized_host=normalized_host,
        )
        for s in user.sessions
    }

    return MsgspecResponse(
        ApiUserDetail(
            user=ApiUser.from_db(user),
            credentials={c.uuid: c for c in user.credentials},
            aaguid_info={
                k: ApiAaguidInfo(**v)
                for k, v in aaguid_mod.filter(
                    c.aaguid for c in user.credentials
                ).items()
            },
            sessions=sessions,
            org=ApiOrg.from_db(user.org),
            role=ApiRole.from_db(user.role),
        )
    )


@app.patch("/users/{user_uuid}/info")
async def admin_update_user_info(
    user_uuid: UUID,
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    """Update user profile info (display_name, email, preferred_username, telephone).

    Pass only the fields you want to update. Use null to clear optional fields.
    """
    try:
        user = db.data().users[user_uuid]
    except KeyError:
        raise HTTPException(status_code=404, detail="User not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, user.org.uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )

    kwargs = {}
    if "display_name" in payload:
        name = (payload["display_name"] or "").strip()
        if not name:
            raise HTTPException(status_code=400, detail="display_name cannot be empty")
        if len(name) > 64:
            raise HTTPException(status_code=400, detail="display_name too long")
        kwargs["display_name"] = name
    if "email" in payload:
        kwargs["email"] = payload["email"]
    if "preferred_username" in payload:
        kwargs["preferred_username"] = payload["preferred_username"]
    if "telephone" in payload:
        kwargs["telephone"] = payload["telephone"]

    if not kwargs:
        raise HTTPException(status_code=400, detail="No fields to update")

    db.update_user_info(user_uuid, **kwargs, ctx=ctx)
    return {"status": "ok"}


@app.delete("/users/{user_uuid}")
async def admin_delete_user(
    user_uuid: UUID,
    request: Request,
    auth=AUTH_COOKIE,
):
    """Delete a user and all their credentials/sessions."""
    try:
        user = db.data().users[user_uuid]
    except KeyError:
        raise HTTPException(status_code=404, detail="User not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
        max_age="5m",
    )
    if not can_manage_org(ctx, user.org.uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )
    # Prevent admin from deleting themselves
    if ctx.user.uuid == user_uuid:
        raise ValueError("Cannot delete your own account")
    db.delete_user(user_uuid, ctx=ctx)
    return {"status": "ok"}


@app.delete("/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
    user_uuid: UUID,
    credential_uuid: UUID,
    request: Request,
    auth=AUTH_COOKIE,
):
    try:
        user = db.data().users[user_uuid]
    except KeyError:
        raise HTTPException(status_code=404, detail="User not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
        max_age="5m",
    )
    if not can_manage_org(ctx, user.org.uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )
    db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
    return {"status": "ok"}


@app.delete("/users/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session(
    user_uuid: UUID,
    session_id: str,
    request: Request,
    auth=AUTH_COOKIE,
):
    try:
        user = db.data().users[user_uuid]
    except KeyError:
        raise HTTPException(status_code=404, detail="User not found")
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )
    if not can_manage_org(ctx, user.org.uuid):
        raise authz.AuthException(
            status_code=403, detail="Insufficient permissions", mode="forbidden"
        )

    session_key = session_id

    target_session = db.data().sessions.get(session_key)
    if not target_session or target_session.user_uuid != user_uuid:
        raise HTTPException(status_code=404, detail="Session not found")

    db.delete_session(session_key, ctx=ctx, action="admin:delete_session")

    # Check if admin terminated their own session
    current_terminated = session_key == ctx.session.key
    return {"status": "ok", "current_session_terminated": current_terminated}


# -------------------- Permissions (global) --------------------


def _validate_permission_domain(domain: str | None) -> None:
    """Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
    if domain is None:
        return

    # Allow OIDC client UUIDs (used for groups claim)
    try:
        client_uuid = UUID(domain)
        if client_uuid in db.data().oidc.clients:
            return
    except ValueError:
        pass

    rp_id = passkey.instance.rp_id
    if domain == rp_id or domain.endswith(f".{rp_id}"):
        return
    raise ValueError(
        f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID"
    )


def _check_admin_lockout(
    perm_uuid: str, new_domain: str | None, current_host: str | None
) -> None:
    """Check if setting domain on auth:admin would lock out the admin.

    Raises ValueError if this change would result in no auth:admin permissions
    being accessible from the current host.
    """

    normalized_host = normalize_host(current_host)
    host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None

    # Get all auth:admin permissions
    all_perms = list(db.data().permissions.values())
    admin_perms = [p for p in all_perms if p.scope == "auth:admin"]

    # Check if at least one auth:admin would remain accessible
    for p in admin_perms:
        # If this is the permission being modified, use the new domain
        domain = new_domain if str(p.uuid) == perm_uuid else p.domain

        # No domain restriction = accessible from anywhere
        if domain is None:
            return

        # Domain matches current host
        if host_without_port and domain == host_without_port:
            return

    # No accessible auth:admin permission would remain
    raise ValueError(
        "Cannot set this domain restriction: it would lock you out of admin access. "
        "Ensure at least one auth:admin permission remains accessible from your current host."
    )


def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
    """Check if deleting an auth:admin permission would lock out the admin.

    Raises ValueError if this deletion would result in no auth:admin permissions
    being accessible from the current host.
    """

    normalized_host = normalize_host(current_host)
    host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None

    # Get all auth:admin permissions
    all_perms = list(db.data().permissions.values())
    admin_perms = [p for p in all_perms if p.scope == "auth:admin"]

    # Check if at least one auth:admin would remain accessible after deletion
    for p in admin_perms:
        # Skip the permission being deleted
        if str(p.uuid) == perm_uuid:
            continue

        # No domain restriction = accessible from anywhere
        if p.domain is None:
            return

        # Domain matches current host
        if host_without_port and p.domain == host_without_port:
            return

    # No accessible auth:admin permission would remain
    raise ValueError(
        "Cannot delete this permission: it would lock you out of admin access. "
        "Ensure at least one auth:admin permission remains accessible from your current host."
    )


@app.get("/info")
async def admin_info(request: Request, auth=AUTH_COOKIE):
    ctx = await authz.verify(
        auth,
        ["auth:admin", "auth:org:admin"],
        match=permutil.has_any,
        host=request.headers.get("host"),
    )

    # Orgs
    orgs = list(db.data().orgs.values())
    if not master_admin(ctx):
        # Org admins can only see their own organization
        orgs = [o for o in orgs if o.uuid == ctx.org.uuid]

    def org_to_dict(o):
        roles = o.roles
        return ApiOrgResponse(
            org=ApiOrg.from_db(o),
            permissions={p.uuid: p for p in o.permissions},
            roles={r.uuid: r for r in roles},
            users={u.uuid: u for r in roles for u in r.users},
        )

    orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}

    # Permissions
    perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
    perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}

    # OIDC Clients (master admin only)
    oidc_clients_dict = {}
    if master_admin(ctx):
        clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
        sessions = db.data().sessions
        # Count active sessions per client
        client_session_counts = {}
        for session in sessions.values():
            if session.client_uuid:
                client_session_counts[session.client_uuid] = (
                    client_session_counts.get(session.client_uuid, 0) + 1
                )
        oidc_clients_dict = {
            client.uuid: ApiOidcClient.from_db(
                client, client_session_counts.get(client.uuid, 0)
            )
            for client in clients
        }

    return MsgspecResponse(
        ApiAdminInfo(
            orgs=orgs_dict,
            permissions=perms_dict,
            oidc_clients=oidc_clients_dict,
        )
    )


@app.post("/permissions")
async def admin_create_permission(
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    ctx = await authz.verify(
        auth,
        ["auth:admin"],
        host=request.headers.get("host"),
        match=permutil.has_all,
        max_age="5m",
    )

    scope = payload.get("scope") or payload.get(
        "id"
    )  # Support both for backwards compat
    display_name = payload.get("display_name")
    domain = payload.get("domain") or None  # Treat empty string as None
    if not scope or not display_name:
        raise ValueError("scope and display_name are required")
    querysafe.assert_safe(scope, field="scope")
    _validate_permission_domain(domain)
    db.create_permission(
        PermDC.create(scope=scope, display_name=display_name, domain=domain),
        ctx=ctx,
    )
    return {"status": "ok"}


@app.patch("/permission")
async def admin_update_permission(
    request: Request,
    auth=AUTH_COOKIE,
    permission_uuid: UUID = Query(...),
    display_name: str | None = Query(None),
    scope: str | None = Query(None),
    domain: str | None = Query(None),
):
    ctx = await authz.verify(
        auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
    )

    # Get existing permission
    perm = db.data().permissions.get(permission_uuid)

    # Update fields that were provided
    new_scope = scope if scope is not None else perm.scope
    new_display_name = display_name if display_name is not None else perm.display_name
    domain_value = domain if domain else None

    # Sanity check: prevent changing the auth:admin permission scope
    if perm.scope == "auth:admin" and new_scope != "auth:admin":
        raise ValueError("Cannot rename the master admin permission")

    if not new_display_name:
        raise ValueError("display_name is required")
    querysafe.assert_safe(new_scope, field="scope")
    _validate_permission_domain(domain_value)

    # Safety check: prevent admin lockout when setting domain on auth:admin
    if perm.scope == "auth:admin" or new_scope == "auth:admin":
        _check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))

    db.update_permission(
        uuid=perm.uuid,
        scope=new_scope,
        display_name=new_display_name,
        domain=domain_value,
        ctx=ctx,
    )
    return {"status": "ok"}


@app.delete("/permission")
async def admin_delete_permission(
    request: Request,
    permission_uuid: UUID = Query(...),
    auth=AUTH_COOKIE,
):
    ctx = await authz.verify(
        auth,
        ["auth:admin"],
        host=request.headers.get("host"),
        match=permutil.has_all,
        max_age="5m",
    )

    # Get the permission to check its scope
    perm = db.data().permissions.get(permission_uuid)

    # Sanity check: prevent deleting critical permissions if it would lock out admin
    if perm.scope == "auth:admin":
        _check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))

    db.delete_permission(permission_uuid, ctx=ctx)
    return {"status": "ok"}


# -------------------- OIDC Clients --------------------


@app.post("/oidc-clients")
async def admin_create_oidc_client(
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    """Create a new OIDC client (master admin only)."""
    ctx = await authz.verify(
        auth,
        ["auth:admin"],
        host=request.headers.get("host"),
        match=permutil.has_all,
        max_age="5m",
    )
    if not master_admin(ctx):
        raise authz.AuthException(
            status_code=403,
            detail="Only master admin can manage OIDC clients",
            mode="forbidden",
        )

    # Client ID and secret hash are generated client-side
    client_id = payload.get("client_id", "").strip()
    secret_hash_hex = payload.get("secret_hash", "").strip()
    name = payload.get("name", "").strip()
    redirect_uris = payload.get("redirect_uris", [])
    backchannel_logout_uri = payload.get("backchannel_logout_uri")
    if isinstance(backchannel_logout_uri, str):
        backchannel_logout_uri = backchannel_logout_uri.strip() or None

    if not client_id or not secret_hash_hex:
        raise ValueError("client_id and secret_hash are required")

    try:
        client_uuid = UUID(client_id)
    except (ValueError, AttributeError):
        raise ValueError("client_id must be a valid UUID")

    try:
        secret_hash = bytes.fromhex(secret_hash_hex)
    except ValueError:
        raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
    if len(secret_hash) != 32:
        raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")

    if not isinstance(redirect_uris, list):
        raise ValueError("redirect_uris must be a list")

    # Validate redirect URIs
    for uri in redirect_uris:
        if not isinstance(uri, str) or not uri.startswith("http"):
            raise ValueError(f"Invalid redirect URI: {uri}")

    if backchannel_logout_uri and not backchannel_logout_uri.startswith("http"):
        raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")

    client = Client(
        client_secret_hash=secret_hash,
        name=name,
        redirect_uris=redirect_uris,
        backchannel_logout_uri=backchannel_logout_uri,
    )
    client.uuid = client_uuid

    db.create_oid_client(client, ctx=ctx)

    return {"status": "ok", "client_id": str(client.uuid)}


@app.patch("/oidc-clients/{client_uuid}")
async def admin_update_oidc_client(
    client_uuid: UUID,
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    """Update an OIDC client's name and redirect URIs (master admin only)."""
    ctx = await authz.verify(
        auth,
        ["auth:admin"],
        host=request.headers.get("host"),
        match=permutil.has_all,
        max_age="5m",
    )
    if not master_admin(ctx):
        raise authz.AuthException(
            status_code=403,
            detail="Only master admin can manage OIDC clients",
            mode="forbidden",
        )

    name = payload.get("name", "").strip() if "name" in payload else None
    redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
    secret_hash_hex = (
        payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
    )
    backchannel_logout_uri = (
        payload.get("backchannel_logout_uri")
        if "backchannel_logout_uri" in payload
        else _UNSET
    )
    if isinstance(backchannel_logout_uri, str):
        backchannel_logout_uri = backchannel_logout_uri.strip() or None

    if name is not None and not name:
        raise ValueError("Client name cannot be empty")

    if redirect_uris is not None:
        if not isinstance(redirect_uris, list):
            raise ValueError("redirect_uris must be a list")
        # Validate redirect URIs
        for uri in redirect_uris:
            if not isinstance(uri, str) or not uri.startswith("http"):
                raise ValueError(f"Invalid redirect URI: {uri}")

    if (
        backchannel_logout_uri is not _UNSET
        and backchannel_logout_uri
        and not backchannel_logout_uri.startswith("http")
    ):
        raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")

    secret_hash = None
    if secret_hash_hex:
        try:
            secret_hash = bytes.fromhex(secret_hash_hex)
        except ValueError:
            raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
        if len(secret_hash) != 32:
            raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")

    try:
        db.update_oid_client(
            client_uuid,
            name=name,
            redirect_uris=redirect_uris,
            secret_hash=secret_hash,
            backchannel_logout_uri=backchannel_logout_uri,
            ctx=ctx,
        )
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {"status": "ok"}


@app.post("/oidc-clients/{client_uuid}/reset-secret")
async def admin_reset_oidc_client_secret(
    client_uuid: UUID,
    request: Request,
    payload: dict = Body(...),
    auth=AUTH_COOKIE,
):
    """Reset an OIDC client's secret (master admin only).

    The new secret is generated client-side; only the SHA-256 hash is sent.
    """
    ctx = await authz.verify(
        auth,
        ["auth:admin"],
        host=request.headers.get("host"),
        match=permutil.has_all,
        max_age="5m",
    )
    if not master_admin(ctx):
        raise authz.AuthException(
            status_code=403,
            detail="Only master admin can manage OIDC clients",
            mode="forbidden",
        )

    secret_hash_hex = payload.get("secret_hash", "").strip()
    if not secret_hash_hex:
        raise ValueError("secret_hash is required")
    try:
        secret_hash = bytes.fromhex(secret_hash_hex)
    except ValueError:
        raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
    if len(secret_hash) != 32:
        raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")

    try:
        db.reset_oid_client_secret(client_uuid, secret_hash, ctx=ctx)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {"status": "ok"}


@app.delete("/oidc-clients/{client_uuid}")
async def admin_delete_oidc_client(
    client_uuid: UUID,
    request: Request,
    auth=AUTH_COOKIE,
):
    """Delete an OIDC client (master admin only)."""
    ctx = await authz.verify(
        auth,
        ["auth:admin"],
        host=request.headers.get("host"),
        match=permutil.has_all,
        max_age="5m",
    )
    if not master_admin(ctx):
        raise authz.AuthException(
            status_code=403,
            detail="Only master admin can manage OIDC clients",
            mode="forbidden",
        )

    try:
        db.delete_oid_client(client_uuid, ctx=ctx)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return {"status": "ok"}
