//! Capability-based authorization — see blueprint §11.6.
//!
//! Single entry point for every admin endpoint: `can(repo, principal,
//! capability)` returns whether the caller can perform the action.
//! Handlers call `require` (which wraps `can` + a `Forbidden` error)
//! after loading the resource so the capability binds to the resource's
//! actual `app_id`, not a path param the caller controls.
//!
//! Three layers of intersection, evaluated in order:
//!
//!   1. **Role grant** — does the caller's `InstanceRole` plus any
//!      `app_members` row authorize this capability?
//!   2. **Scope intersection** — if the principal came from an API key
//!      (`principal.scopes.is_some()`), does the key's scope set cover
//!      the capability's required scope?
//!   3. **App binding** — if the key was minted bound to a specific
//!      app (`principal.app_binding`), does the capability target the
//!      same app? (Instance-level capabilities are denied for bound
//!      keys; the mint handler also rejects the combination upfront.)
//!
//! The capability set is intentionally finer-grained than the seven
//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under
//! the `script:write` / `route:write` scopes respectively). Keeping
//! capabilities precise lets a `script:write`-only key write scripts
//! without also being able to mutate routes. The scope set stays at
//! seven values — capabilities are the internal check, scopes are the
//! external user-facing label.

use async_trait::async_trait;
use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};

/// Things a caller can attempt to do. Each app-scoped variant carries
/// the `AppId` of the resource the action targets — handlers compute
/// it from the loaded resource (e.g., `script.app_id`), not from a
/// path param.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
    /// Create a new app. Owner / admin only.
    InstanceCreateApp,
    /// Create a new group (root-level). Owner / admin only — a Member
    /// creates subgroups under a group they group-admin (gated by
    /// `GroupAdmin(parent)` at the handler), not via this instance cap.
    InstanceCreateGroup,
    /// Read group metadata + list its subgroups/apps. Viewer+ on the
    /// group (inherited from any ancestor); implicit for admin / owner.
    GroupRead(GroupId),
    /// Rename / edit group metadata, move apps into it. Editor+ on the
    /// group.
    GroupWrite(GroupId),
    /// Group settings: delete, reparent, manage group members. group_admin
    /// on the group (inherited from any ancestor).
    GroupAdmin(GroupId),
    /// Create / update / delete admin_users rows (other than self
    /// password change, which is a separate flow). Owner / admin.
    InstanceManageUsers,
    /// Mutate instance-wide configuration (sandbox ceiling, etc.).
    /// Owner only.
    InstanceManageSettings,
    /// Read app metadata, scripts, routes. Viewer / editor / app_admin
    /// (member); implicit for admin / owner.
    AppRead(AppId),
    /// Create / update / delete a script in this app.
    AppWriteScript(AppId),
    /// Create / update / delete a route in this app.
    AppWriteRoute(AppId),
    /// Manage domain claims on this app (add / remove).
    AppManageDomains(AppId),
    /// App settings + delete app. app_admin only (or owner via
    /// implicit grant).
    AppAdmin(AppId),
    /// Read execution logs for scripts in this app.
    AppLogRead(AppId),
    /// Read entries from this app's KV store (v1.1.1). Granted to
    /// `viewer`+ in the per-app role table. Maps to `script:read` on
    /// API keys — the seven-scope vocabulary stays locked.
    AppKvRead(AppId),
    /// Write entries to this app's KV store (v1.1.1). Granted to
    /// `editor`+. Maps to `script:write` on API keys.
    AppKvWrite(AppId),
    /// Read documents from this app's docs store (v1.1.2). Same trust
    /// shape as KV read — granted to `viewer`+, maps to `script:read`
    /// on API keys. Honors the seven-scope commitment.
    AppDocsRead(AppId),
    /// Write documents to this app's docs store (v1.1.2). Same trust
    /// shape as KV write — granted to `editor`+, maps to
    /// `script:write` on API keys.
    AppDocsWrite(AppId),
    /// Make an outbound HTTP request from a script in this app
    /// (v1.1.4). Maps to `script:write` on API keys: any outbound
    /// request can exfiltrate data — including read methods like GET —
    /// so the conservative write mapping is correct. Splitting
    /// read/write is a v1.2+ refinement. Granted to `editor`+.
    AppHttpRequest(AppId),
    /// Read blobs from this app's files store (v1.1.5). Same trust
    /// shape as KV/docs read — granted to `viewer`+, maps to
    /// `script:read` on API keys. Honors the seven-scope commitment.
    AppFilesRead(AppId),
    /// Write blobs to this app's files store (v1.1.5). Granted to
    /// `editor`+, maps to `script:write` on API keys.
    AppFilesWrite(AppId),
    /// Publish a durable pub/sub message from a script in this app
    /// (v1.1.5). Maps to `script:write` on API keys (a publish is a
    /// write that fans out to subscribers). Granted to `editor`+.
    AppPubsubPublish(AppId),
    /// Enqueue a message onto this app's queue from a script (v1.1.9).
    /// Maps to `script:write` on API keys (an enqueue is a write that
    /// fans out to the registered consumer). Granted to `editor`+.
    /// `depth` / `depth_pending` are read-only inspection and don't gate
    /// — scripts in the app can always see their own queue depths.
    AppQueueEnqueue(AppId),
    /// Read a decrypted secret from this app's secrets store (v1.1.7).
    /// Same trust shape as KV/docs/files read — granted to `viewer`+,
    /// maps to `script:read` on API keys. Honors the seven-scope
    /// commitment.
    AppSecretsRead(AppId),
    /// Write (set/delete) a secret in this app's secrets store (v1.1.7).
    /// Granted to `editor`+, maps to `script:write` on API keys.
    AppSecretsWrite(AppId),
    /// Read this app's resolved config vars (Phase 3). Same trust shape as
    /// secrets-read — granted to `viewer`+, maps to `script:read`.
    AppVarsRead(AppId),
    /// Write (set/delete) an app-owned config var (Phase 3). Granted to
    /// `editor`+, maps to `script:write`.
    AppVarsWrite(AppId),
    /// Read a group's config vars (Phase 3). Resolved via the group
    /// ancestor walk; viewer+ on the group.
    GroupVarsRead(GroupId),
    /// Write (set/delete) a group-owned config var (Phase 3). editor+ on
    /// the group.
    GroupVarsWrite(GroupId),
    /// Read a group-owned secret's VALUE (Phase 3, the human-read gate).
    /// group_admin on the owning group — distinct from runtime injection,
    /// which an inheriting app does without this check.
    GroupSecretsRead(GroupId),
    /// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
    /// group.
    GroupSecretsWrite(GroupId),
    /// Read (get / list) group-owned scripts (Phase 4). Resolved via the
    /// group ancestor walk; viewer+ on the group. Maps to `script:read`.
    GroupScriptsRead(GroupId),
    /// Create / update / delete a group-owned script (Phase 4). editor+ on
    /// the group; maps to `script:write` — the same tier as `AppWriteScript`
    /// for an app, lifted to the group owner.
    GroupScriptsWrite(GroupId),
    /// Read a group-owned shared KV collection (§11.6). Resolved via the group
    /// ancestor walk; viewer+ on the owning group. Maps to `script:read`. The
    /// reads-open trust model means a script with no principal (anonymous
    /// public HTTP) bypasses this check — the structural subtree boundary
    /// (the collection only resolves for apps under the owning group) is the
    /// hard isolation; this cap refines access for authenticated callers.
    GroupKvRead(GroupId),
    /// Write a group-owned shared KV collection (§11.6). editor+ on the owning
    /// group; maps to `script:write`. Unlike the read cap, a write FAILS CLOSED
    /// for an anonymous principal — mutation of shared data always requires an
    /// authenticated caller (enforced at the service layer, not by skipping).
    GroupKvWrite(GroupId),
    /// Read a group-owned shared DOCS collection (§11.6). Viewer+ on the owning
    /// group; same reads-open trust model as `GroupKvRead`.
    GroupDocsRead(GroupId),
    /// Write a group-owned shared DOCS collection (§11.6). editor+ on the owning
    /// group; fails closed for an anonymous principal, like `GroupKvWrite`.
    GroupDocsWrite(GroupId),
    /// Read a group-owned shared FILES collection (§11.6). Viewer+ on the owning
    /// group; same reads-open trust model as `GroupKvRead`.
    GroupFilesRead(GroupId),
    /// Write a group-owned shared FILES collection (§11.6). editor+ on the owning
    /// group; fails closed for an anonymous principal, like `GroupKvWrite`.
    GroupFilesWrite(GroupId),
    /// Publish to a group-owned shared TOPIC (§11.6 D2). editor+ on the owning
    /// group; fails closed for an anonymous principal, like `GroupKvWrite` — a
    /// publish is a write to shared state.
    GroupPubsubPublish(GroupId),
    /// Enqueue into a group-owned shared QUEUE (§11.6 D3). editor+ on the owning
    /// group; fails closed for an anonymous principal, like `GroupKvWrite` — an
    /// enqueue is a write to shared state.
    GroupQueueEnqueue(GroupId),
    /// Send an outbound email from a script in this app (v1.1.7). Maps
    /// to `script:write` on API keys (sending mail is an outbound
    /// side-effect like an HTTP request). Granted to `editor`+.
    AppEmailSend(AppId),
    /// Create / list / delete triggers for this app (v1.1.1). Maps to
    /// `app:admin` on API keys — triggers are app-configuration acts
    /// rather than data-plane access. Granted to `app_admin`+.
    AppManageTriggers(AppId),
    /// Replay / resolve dead-letter rows for this app (v1.1.1). Maps
    /// to `app:admin` on API keys. Public-HTTP scripts (principal None)
    /// fail this check — managing dead letters is an admin act.
    AppDeadLetterManage(AppId),
    /// Register / list / update / delete externally-subscribable topics
    /// for this app (v1.1.6). Maps to `app:admin` on API keys —
    /// externalizing a topic is an app-configuration act with security
    /// weight (it opens an internal pub/sub topic to outside SSE
    /// subscribers). Granted to `app_admin`+.
    AppTopicManage(AppId),
    /// Read app-user records (v1.1.8 `users::*`) — `get`,
    /// `find_by_email`, `list`, `verify`, `has_role`. Same trust shape
    /// as KV/docs/files read — granted to `viewer`+, maps to
    /// `script:read` on API keys. Honors the seven-scope commitment.
    AppUsersRead(AppId),
    /// Write app-user records (v1.1.8 `users::*`) — `create`, `update`,
    /// `delete`, role mutations, login/logout, password reset, email
    /// verification, invitation acceptance. Granted to `editor`+, maps
    /// to `script:write` on API keys.
    AppUsersWrite(AppId),
    /// Admin-tier app-user actions (v1.1.8) — issuing invitations,
    /// admin-mediated reset-password / revoke-sessions HTTP endpoints.
    /// Maps to `script:write` on API keys (no new scope per the
    /// seven-scope commitment); the additional gate vs `Write` lives in
    /// the per-app role chain (`app_admin`+ only).
    AppUsersAdmin(AppId),
    /// F-S-012 (v1.1.9+): `invoke()` / `invoke_async()` synchronously
    /// trigger another script in the same app. Same-app isolation is
    /// already enforced (cross-app calls are rejected), but within one
    /// app an anonymous public-HTTP script could otherwise trigger any
    /// other script — including ones that hold capabilities the
    /// original caller shouldn't. Gate authenticated callers on
    /// AppInvoke; anonymous callers continue to skip the check under
    /// the script-as-gate convention.
    AppInvoke(AppId),
}

impl Capability {
    /// Extract the `AppId` for app-scoped capabilities; `None` for
    /// instance-scoped ones. Used by the app-binding check on API keys.
    #[must_use]
    pub const fn app_id(self) -> Option<AppId> {
        match self {
            Self::InstanceCreateApp
            | Self::InstanceManageUsers
            | Self::InstanceManageSettings
            | Self::InstanceCreateGroup
            // Group-scoped caps carry a GroupId, not an AppId. They return
            // None here so a bound API key (which can only target its one
            // app) is denied group management at the binding layer.
            | Self::GroupRead(_)
            | Self::GroupWrite(_)
            | Self::GroupAdmin(_)
            | Self::GroupVarsRead(_)
            | Self::GroupVarsWrite(_)
            | Self::GroupSecretsRead(_)
            | Self::GroupSecretsWrite(_)
            | Self::GroupScriptsRead(_)
            | Self::GroupScriptsWrite(_)
            | Self::GroupKvRead(_)
            | Self::GroupKvWrite(_)
            | Self::GroupDocsRead(_)
            | Self::GroupDocsWrite(_)
            | Self::GroupFilesRead(_)
            | Self::GroupFilesWrite(_)
            | Self::GroupPubsubPublish(_)
            | Self::GroupQueueEnqueue(_) => None,
            Self::AppRead(id)
            | Self::AppWriteScript(id)
            | Self::AppWriteRoute(id)
            | Self::AppManageDomains(id)
            | Self::AppAdmin(id)
            | Self::AppLogRead(id)
            | Self::AppKvRead(id)
            | Self::AppKvWrite(id)
            | Self::AppDocsRead(id)
            | Self::AppDocsWrite(id)
            | Self::AppHttpRequest(id)
            | Self::AppFilesRead(id)
            | Self::AppFilesWrite(id)
            | Self::AppPubsubPublish(id)
            | Self::AppQueueEnqueue(id)
            | Self::AppSecretsRead(id)
            | Self::AppSecretsWrite(id)
            | Self::AppVarsRead(id)
            | Self::AppVarsWrite(id)
            | Self::AppEmailSend(id)
            | Self::AppManageTriggers(id)
            | Self::AppDeadLetterManage(id)
            | Self::AppTopicManage(id)
            | Self::AppUsersRead(id)
            | Self::AppUsersWrite(id)
            | Self::AppUsersAdmin(id)
            | Self::AppInvoke(id) => Some(id),
        }
    }

    /// The single scope that authorizes this capability on an API key.
    /// Strict mapping — a `script:write` key cannot read scripts unless
    /// it also carries `script:read`. The intent is predictability: a
    /// key has exactly the scopes it was minted with, no implicit
    /// upgrades.
    #[must_use]
    pub const fn required_scope(self) -> Scope {
        match self {
            Self::InstanceCreateApp
            | Self::InstanceManageUsers
            | Self::InstanceManageSettings
            | Self::InstanceCreateGroup => Scope::InstanceAdmin,
            Self::AppRead(_)
            | Self::AppKvRead(_)
            | Self::AppDocsRead(_)
            | Self::AppFilesRead(_)
            | Self::AppSecretsRead(_)
            | Self::AppUsersRead(_)
            | Self::AppVarsRead(_)
            | Self::GroupRead(_)
            | Self::GroupVarsRead(_)
            | Self::GroupScriptsRead(_)
            | Self::GroupKvRead(_)
            | Self::GroupDocsRead(_)
            | Self::GroupFilesRead(_) => Scope::ScriptRead,
            Self::AppWriteScript(_)
            | Self::AppKvWrite(_)
            | Self::AppDocsWrite(_)
            | Self::AppHttpRequest(_)
            | Self::AppFilesWrite(_)
            | Self::AppPubsubPublish(_)
            | Self::AppQueueEnqueue(_)
            | Self::AppSecretsWrite(_)
            | Self::AppEmailSend(_)
            | Self::AppUsersWrite(_)
            | Self::AppUsersAdmin(_)
            | Self::AppVarsWrite(_)
            // Group-secret WRITE is editor-role-gated (same tier as
            // GroupVarsWrite), so its API-key scope matches: script:write,
            // not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
            // the admin tier below.
            | Self::GroupSecretsWrite(_)
            | Self::GroupScriptsWrite(_)
            | Self::GroupKvWrite(_)
            | Self::GroupDocsWrite(_)
            | Self::GroupFilesWrite(_)
            | Self::GroupPubsubPublish(_)
            | Self::GroupQueueEnqueue(_)
            | Self::AppInvoke(_) => Scope::ScriptWrite,
            Self::AppWriteRoute(_) => Scope::RouteWrite,
            Self::AppManageDomains(_) => Scope::DomainManage,
            Self::AppAdmin(_)
            | Self::AppManageTriggers(_)
            | Self::AppDeadLetterManage(_)
            | Self::AppTopicManage(_)
            | Self::GroupWrite(_)
            | Self::GroupAdmin(_)
            | Self::GroupVarsWrite(_)
            | Self::GroupSecretsRead(_) => Scope::AppAdmin,
            Self::AppLogRead(_) => Scope::LogRead,
        }
    }
}

/// Repo seam for membership lookups. Implemented in the DB-backed
/// repos crate (`app_members_repo.rs`); keeping it as a trait here
/// means unit tests can stub it.
#[async_trait]
pub trait AuthzRepo: Send + Sync {
    /// Direct `app_members` row for (user, app). The single-row lookup
    /// used by member-management surfaces and as the fallback below.
    async fn membership(
        &self,
        user_id: UserId,
        app_id: AppId,
    ) -> Result<Option<AppRole>, AuthzError>;

    /// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
    /// the app's own `app_members` row folded with every `group_members`
    /// row on any ancestor group, max-by-authority. This is what `can()`
    /// consults so a `group_admin` on an ancestor is implicitly app_admin
    /// on the app.
    ///
    /// Default = direct membership only (no inheritance), so the many test
    /// stubs that model no group tree keep their existing behavior; the
    /// Postgres repo overrides this with an ancestor-walking CTE.
    async fn effective_app_role(
        &self,
        user_id: UserId,
        app_id: AppId,
    ) -> Result<Option<AppRole>, AuthzError> {
        self.membership(user_id, app_id).await
    }

    /// Highest effective role on a *group* node — the group's own
    /// ancestor walk over `group_members`. Gates the group-management
    /// capabilities. Default = no grant; the Postgres repo overrides it.
    async fn effective_group_role(
        &self,
        _user_id: UserId,
        _group_id: GroupId,
    ) -> Result<Option<AppRole>, AuthzError> {
        Ok(None)
    }
}

/// Repo errors surface here so handlers can map them to 500 without
/// dragging sqlx types across the boundary.
#[derive(Debug, thiserror::Error)]
pub enum AuthzError {
    #[error("authorization repo error: {0}")]
    Repo(String),
}

/// Decision flavor returned by `can` — distinguishes outright denial
/// from a partial answer that requires further checks (none today,
/// but the shape lets us add audit/explain mode later without rewriting
/// every caller).
#[must_use = "an authorization decision must be acted on"]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
    Allow,
    Deny,
}

impl Decision {
    #[must_use]
    pub const fn is_allow(self) -> bool {
        matches!(self, Self::Allow)
    }
}

/// Core authorization check. Walks the three intersection layers in
/// order and returns the resulting `Decision`.
pub async fn can(
    repo: &dyn AuthzRepo,
    principal: &Principal,
    cap: Capability,
) -> Result<Decision, AuthzError> {
    if !role_grants(repo, principal, cap).await? {
        return Ok(Decision::Deny);
    }
    if !scope_allows(principal, cap) {
        return Ok(Decision::Deny);
    }
    if !binding_allows(principal, cap) {
        return Ok(Decision::Deny);
    }
    Ok(Decision::Allow)
}

/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny.
/// Handlers call this so the `?` operator threads the 403 through
/// naturally.
///
/// # Errors
///
/// Returns `AuthzDenied::Denied` when the capability is not granted,
/// or `AuthzDenied::Repo` if the underlying membership lookup fails.
pub async fn require(
    repo: &dyn AuthzRepo,
    principal: &Principal,
    cap: Capability,
) -> Result<(), AuthzDenied> {
    match can(repo, principal, cap).await {
        Ok(Decision::Allow) => Ok(()),
        Ok(Decision::Deny) => Err(AuthzDenied::Denied),
        Err(e) => Err(AuthzDenied::Repo(e)),
    }
}

#[derive(Debug, thiserror::Error)]
pub enum AuthzDenied {
    #[error("forbidden")]
    Denied,
    #[error(transparent)]
    Repo(#[from] AuthzError),
}

/// Run [`require`] for a known principal and map the `AuthzDenied` verdict onto
/// the caller's service-specific error via `forbidden`/`backend`. The single
/// home for the `Denied → forbidden() / Repo(e) → backend(e)` mapping that
/// [`script_gate`], [`script_gate_require_principal`], and the stateful services
/// share.
///
/// # Errors
///
/// `forbidden()` on `AuthzDenied::Denied`; `backend(repo_err.to_string())` on
/// `AuthzDenied::Repo`.
pub async fn require_mapped<E>(
    repo: &dyn AuthzRepo,
    principal: &Principal,
    cap: Capability,
    forbidden: impl FnOnce() -> E,
    backend: impl FnOnce(String) -> E,
) -> Result<(), E> {
    match require(repo, principal, cap).await {
        Ok(()) => Ok(()),
        Err(AuthzDenied::Denied) => Err(forbidden()),
        Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
    }
}

/// Script-as-gate authz: anonymous public-HTTP scripts skip the check
/// (`cx.principal` is `None`); authenticated callers must hold `cap`.
///
/// Replaces the open-coded
/// `if let Some(p) = cx.principal { authz::require(...).await.map_err(...)? }`
/// pattern across every stateful service. `forbidden` is called when
/// the membership lookup returns `Denied`; `backend` is called when the
/// underlying repo errors. Both closures map to the caller's service-
/// specific error enum.
///
/// # Errors
///
/// Returns the result of `forbidden(())` on `AuthzDenied::Denied`, or
/// `backend(repo_err.to_string())` on `AuthzDenied::Repo(repo_err)`.
pub async fn script_gate<E>(
    repo: &dyn AuthzRepo,
    cx: &picloud_shared::SdkCallCx,
    cap: Capability,
    forbidden: impl FnOnce() -> E,
    backend: impl FnOnce(String) -> E,
) -> Result<(), E> {
    let Some(principal) = cx.principal.as_ref() else {
        return Ok(());
    };
    require_mapped(repo, principal, cap, forbidden, backend).await
}

/// Like [`script_gate`], but **fails closed on an anonymous principal**: a
/// script with `cx.principal == None` is rejected with `forbidden()` rather
/// than skipped. Used for actions that must always be performed by an
/// authenticated caller even though the surrounding service skips authz for
/// public scripts — e.g. §11.6 group-collection WRITES (reads stay open via
/// `script_gate`, writes require an authenticated editor+ on the owning group).
///
/// # Errors
///
/// Returns `forbidden()` when the principal is absent or the capability is
/// denied, or `backend(repo_err.to_string())` on a repo error.
pub async fn script_gate_require_principal<E>(
    repo: &dyn AuthzRepo,
    cx: &picloud_shared::SdkCallCx,
    cap: Capability,
    forbidden: impl FnOnce() -> E,
    backend: impl FnOnce(String) -> E,
) -> Result<(), E> {
    let Some(principal) = cx.principal.as_ref() else {
        return Err(forbidden());
    };
    require_mapped(repo, principal, cap, forbidden, backend).await
}

// ----------------------------------------------------------------------------
// Layer 1: role-derived grant
// ----------------------------------------------------------------------------

async fn role_grants(
    repo: &dyn AuthzRepo,
    principal: &Principal,
    cap: Capability,
) -> Result<bool, AuthzError> {
    match principal.instance_role {
        InstanceRole::Owner => Ok(true),
        InstanceRole::Admin => Ok(admin_grants(cap)),
        InstanceRole::Member => match cap {
            // Group-management caps resolve against the group ancestor
            // walk (a group_admin on an ancestor is implicitly admin of
            // the descendant group). Routed before member_grants because
            // group caps carry no app_id.
            Capability::GroupRead(g)
            | Capability::GroupWrite(g)
            | Capability::GroupAdmin(g)
            | Capability::GroupVarsRead(g)
            | Capability::GroupVarsWrite(g)
            | Capability::GroupSecretsRead(g)
            | Capability::GroupSecretsWrite(g)
            | Capability::GroupScriptsRead(g)
            | Capability::GroupScriptsWrite(g)
            | Capability::GroupKvRead(g)
            | Capability::GroupKvWrite(g)
            | Capability::GroupDocsRead(g)
            | Capability::GroupDocsWrite(g)
            | Capability::GroupFilesRead(g)
            | Capability::GroupFilesWrite(g)
            | Capability::GroupPubsubPublish(g)
            | Capability::GroupQueueEnqueue(g) => {
                group_member_grants(repo, principal.user_id, cap, g).await
            }
            // Creating a root-level group is an instance act — members
            // can't. (Subgroup creation is gated on GroupAdmin(parent) at
            // the handler, which routes through the arm above.)
            Capability::InstanceCreateGroup => Ok(false),
            _ => member_grants(repo, principal.user_id, cap).await,
        },
    }
}

/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
/// They can create apps, manage users, and take any app-scoped action
/// on any app without an explicit `app_members` row — single-human
/// installs would otherwise need to add themselves to every new app.
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
/// owner-only.
const fn admin_grants(cap: Capability) -> bool {
    !matches!(cap, Capability::InstanceManageSettings)
}

/// Member has zero instance authority. App authority requires an
/// explicit `app_members` row with sufficient `AppRole`.
async fn member_grants(
    repo: &dyn AuthzRepo,
    user_id: UserId,
    cap: Capability,
) -> Result<bool, AuthzError> {
    let Some(app_id) = cap.app_id() else {
        return Ok(false);
    };
    // Effective (inherited) role: the app's own membership folded with any
    // ancestor group membership. A group_admin on an ancestor group is
    // implicitly app_admin here.
    let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
        return Ok(false);
    };
    Ok(role_satisfies(role, cap))
}

/// Member-path resolution for the group-management capabilities. Resolves
/// the caller's effective role on the group (ancestor walk over
/// `group_members`) and checks it covers the requested group action.
async fn group_member_grants(
    repo: &dyn AuthzRepo,
    user_id: UserId,
    cap: Capability,
    group_id: GroupId,
) -> Result<bool, AuthzError> {
    let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
        return Ok(false);
    };
    Ok(group_role_satisfies(role, cap))
}

/// Does the effective group `AppRole` cover the group capability?
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
    match cap {
        // viewer+ reads group metadata, config vars, scripts, and shared KV.
        Capability::GroupRead(_)
        | Capability::GroupVarsRead(_)
        | Capability::GroupScriptsRead(_)
        | Capability::GroupKvRead(_)
        | Capability::GroupDocsRead(_)
        | Capability::GroupFilesRead(_) => true,
        // editor+ writes config vars/secrets/scripts and shared KV.
        Capability::GroupWrite(_)
        | Capability::GroupVarsWrite(_)
        | Capability::GroupSecretsWrite(_)
        | Capability::GroupScriptsWrite(_)
        | Capability::GroupKvWrite(_)
        | Capability::GroupDocsWrite(_)
        | Capability::GroupFilesWrite(_)
        | Capability::GroupPubsubPublish(_)
        | Capability::GroupQueueEnqueue(_) => {
            matches!(role, AppRole::Editor | AppRole::AppAdmin)
        }
        // group_admin manages the group + reads secret VALUES (the
        // human-read gate, distinct from an app's runtime injection).
        Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => {
            matches!(role, AppRole::AppAdmin)
        }
        _ => false,
    }
}

/// Does the per-app `AppRole` cover the capability? Viewer can read;
/// Editor adds script/route/log mutations; AppAdmin adds settings,
/// domain claims, and delete. Roles form a strict subset chain, so
/// the check is "is this capability in the role's set?".
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
    let in_viewer = matches!(
        cap,
        Capability::AppRead(_)
            | Capability::AppLogRead(_)
            | Capability::AppKvRead(_)
            | Capability::AppDocsRead(_)
            | Capability::AppFilesRead(_)
            | Capability::AppSecretsRead(_)
            | Capability::AppUsersRead(_)
            | Capability::AppVarsRead(_)
    );
    let in_editor = in_viewer
        || matches!(
            cap,
            Capability::AppWriteScript(_)
                | Capability::AppWriteRoute(_)
                | Capability::AppKvWrite(_)
                | Capability::AppDocsWrite(_)
                | Capability::AppHttpRequest(_)
                | Capability::AppFilesWrite(_)
                | Capability::AppPubsubPublish(_)
                | Capability::AppQueueEnqueue(_)
                | Capability::AppSecretsWrite(_)
                | Capability::AppEmailSend(_)
                | Capability::AppUsersWrite(_)
                | Capability::AppVarsWrite(_)
                | Capability::AppInvoke(_)
        );
    let in_app_admin = in_editor
        || matches!(
            cap,
            Capability::AppManageDomains(_)
                | Capability::AppAdmin(_)
                | Capability::AppManageTriggers(_)
                | Capability::AppDeadLetterManage(_)
                | Capability::AppTopicManage(_)
                | Capability::AppUsersAdmin(_)
        );
    match role {
        AppRole::Viewer => in_viewer,
        AppRole::Editor => in_editor,
        AppRole::AppAdmin => in_app_admin,
    }
}

// ----------------------------------------------------------------------------
// Layer 2: API-key scope intersection
// ----------------------------------------------------------------------------

fn scope_allows(principal: &Principal, cap: Capability) -> bool {
    match &principal.scopes {
        None => true, // cookie session — full role authority
        Some(scopes) => scopes.contains(&cap.required_scope()),
    }
}

// ----------------------------------------------------------------------------
// Layer 3: API-key app binding
// ----------------------------------------------------------------------------

fn binding_allows(principal: &Principal, cap: Capability) -> bool {
    let Some(bound_app) = principal.app_binding else {
        return true;
    };
    match cap.app_id() {
        // Instance-scoped capability + bound key → always denied. The
        // mint handler also rejects this combination upfront, but
        // defending in depth here means a stale/malformed row can't
        // escalate.
        None => false,
        Some(target_app) => target_app == bound_app,
    }
}

// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use picloud_shared::{AdminUserId, AppId};
    use std::collections::HashMap;
    use tokio::sync::Mutex;

    /// In-memory `AuthzRepo` so the unit tests don't need a database. Models
    /// direct app memberships PLUS a group tree (app→group, group→parent)
    /// and group memberships, so the hierarchy-aware resolution can be
    /// exercised without Postgres — mirroring the recursive-CTE behavior.
    #[derive(Default)]
    struct InMemoryAuthzRepo {
        memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
        app_group: Mutex<HashMap<AppId, GroupId>>,
        group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
        group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
    }

    impl InMemoryAuthzRepo {
        async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
            self.memberships.lock().await.insert((user, app), role);
        }
        /// Register a group node and its parent (`None` = root).
        async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
            self.group_parent.lock().await.insert(group, parent);
        }
        /// Place an app under a group.
        async fn put_app(&self, app: AppId, group: GroupId) {
            self.app_group.lock().await.insert(app, group);
        }
        /// Grant a group-level role.
        async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
            self.group_memberships
                .lock()
                .await
                .insert((user, group), role);
        }

        /// Fold every ancestor group membership starting at `group`,
        /// max-by-authority, into `acc`.
        async fn fold_group_chain(
            &self,
            user_id: UserId,
            mut group: Option<GroupId>,
            mut acc: Option<AppRole>,
        ) -> Option<AppRole> {
            let memberships = self.group_memberships.lock().await;
            let parents = self.group_parent.lock().await;
            let mut hops = 0u32;
            while let Some(g) = group {
                if let Some(r) = memberships.get(&(user_id, g)).copied() {
                    acc = Some(acc.map_or(r, |a| a.max(r)));
                }
                hops += 1;
                if hops > 64 {
                    break;
                }
                group = parents.get(&g).copied().flatten();
            }
            acc
        }
    }

    #[async_trait]
    impl AuthzRepo for InMemoryAuthzRepo {
        async fn membership(
            &self,
            user_id: UserId,
            app_id: AppId,
        ) -> Result<Option<AppRole>, AuthzError> {
            Ok(self
                .memberships
                .lock()
                .await
                .get(&(user_id, app_id))
                .copied())
        }

        async fn effective_app_role(
            &self,
            user_id: UserId,
            app_id: AppId,
        ) -> Result<Option<AppRole>, AuthzError> {
            let direct = self
                .memberships
                .lock()
                .await
                .get(&(user_id, app_id))
                .copied();
            let start = self.app_group.lock().await.get(&app_id).copied();
            Ok(self.fold_group_chain(user_id, start, direct).await)
        }

        async fn effective_group_role(
            &self,
            user_id: UserId,
            group_id: GroupId,
        ) -> Result<Option<AppRole>, AuthzError> {
            Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
        }
    }

    fn principal(role: InstanceRole) -> Principal {
        Principal {
            user_id: AdminUserId::new(),
            instance_role: role,
            scopes: None,
            app_binding: None,
        }
    }

    #[tokio::test]
    async fn owner_can_do_everything() {
        let repo = InMemoryAuthzRepo::default();
        let p = principal(InstanceRole::Owner);
        let app = AppId::new();
        for cap in [
            Capability::InstanceCreateApp,
            Capability::InstanceManageUsers,
            Capability::InstanceManageSettings,
            Capability::AppRead(app),
            Capability::AppWriteScript(app),
            Capability::AppWriteRoute(app),
            Capability::AppManageDomains(app),
            Capability::AppAdmin(app),
            Capability::AppLogRead(app),
        ] {
            assert_eq!(
                can(&repo, &p, cap).await.unwrap(),
                Decision::Allow,
                "owner denied {cap:?}"
            );
        }
    }

    #[tokio::test]
    async fn admin_cannot_manage_instance_settings() {
        let repo = InMemoryAuthzRepo::default();
        let p = principal(InstanceRole::Admin);
        assert_eq!(
            can(&repo, &p, Capability::InstanceManageSettings)
                .await
                .unwrap(),
            Decision::Deny,
        );
    }

    #[tokio::test]
    async fn admin_is_implicit_app_admin_on_every_app() {
        let repo = InMemoryAuthzRepo::default();
        let p = principal(InstanceRole::Admin);
        let app = AppId::new();
        // Instance-scoped allowances.
        assert_eq!(
            can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
            Decision::Allow,
        );
        assert_eq!(
            can(&repo, &p, Capability::InstanceManageUsers)
                .await
                .unwrap(),
            Decision::Allow,
        );
        // Editor-like + app-admin grants both succeed without any
        // app_members row.
        for cap in [
            Capability::AppRead(app),
            Capability::AppWriteScript(app),
            Capability::AppWriteRoute(app),
            Capability::AppLogRead(app),
            Capability::AppManageDomains(app),
            Capability::AppAdmin(app),
        ] {
            assert_eq!(
                can(&repo, &p, cap).await.unwrap(),
                Decision::Allow,
                "admin denied app-scoped capability {cap:?}"
            );
        }
    }

    #[tokio::test]
    async fn member_without_row_is_denied_everywhere() {
        let repo = InMemoryAuthzRepo::default();
        let p = principal(InstanceRole::Member);
        let app = AppId::new();
        for cap in [
            Capability::InstanceCreateApp,
            Capability::InstanceManageUsers,
            Capability::InstanceManageSettings,
            Capability::AppRead(app),
            Capability::AppWriteScript(app),
            Capability::AppWriteRoute(app),
            Capability::AppAdmin(app),
            Capability::AppLogRead(app),
        ] {
            assert_eq!(
                can(&repo, &p, cap).await.unwrap(),
                Decision::Deny,
                "member granted {cap:?} without a membership row"
            );
        }
    }

    #[tokio::test]
    async fn member_with_viewer_role_can_read_but_not_write() {
        let repo = InMemoryAuthzRepo::default();
        let p = principal(InstanceRole::Member);
        let app = AppId::new();
        repo.grant(p.user_id, app, AppRole::Viewer).await;

        assert!(can(&repo, &p, Capability::AppRead(app))
            .await
            .unwrap()
            .is_allow());
        assert!(can(&repo, &p, Capability::AppLogRead(app))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &p, Capability::AppWriteScript(app))
                .await
                .unwrap(),
            Decision::Deny
        );
        assert_eq!(
            can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn member_with_editor_role_can_write_scripts_and_routes() {
        let repo = InMemoryAuthzRepo::default();
        let p = principal(InstanceRole::Member);
        let app = AppId::new();
        repo.grant(p.user_id, app, AppRole::Editor).await;

        assert!(can(&repo, &p, Capability::AppWriteScript(app))
            .await
            .unwrap()
            .is_allow());
        assert!(can(&repo, &p, Capability::AppWriteRoute(app))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
            Decision::Deny
        );
    }

    /// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
    /// (Delete). The script-delete handler gates on the latter so the
    /// API can't be tricked into letting an editor remove the script
    /// they were only allowed to edit.
    #[tokio::test]
    async fn editor_can_write_scripts_but_not_delete_them() {
        let repo = InMemoryAuthzRepo::default();
        let p = principal(InstanceRole::Member);
        let app = AppId::new();
        repo.grant(p.user_id, app, AppRole::Editor).await;

        assert!(can(&repo, &p, Capability::AppWriteScript(app))
            .await
            .unwrap()
            .is_allow());
        // Delete is gated on AppAdmin in the handler — editors must be
        // denied here for that gate to bite.
        assert_eq!(
            can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
            Decision::Deny,
        );
    }

    #[tokio::test]
    async fn member_with_app_admin_role_can_do_app_admin_actions() {
        let repo = InMemoryAuthzRepo::default();
        let p = principal(InstanceRole::Member);
        let app = AppId::new();
        repo.grant(p.user_id, app, AppRole::AppAdmin).await;

        assert!(can(&repo, &p, Capability::AppAdmin(app))
            .await
            .unwrap()
            .is_allow());
        assert!(can(&repo, &p, Capability::AppManageDomains(app))
            .await
            .unwrap()
            .is_allow());
        // Membership in App A does NOT grant access to App B
        let other_app = AppId::new();
        assert_eq!(
            can(&repo, &p, Capability::AppAdmin(other_app))
                .await
                .unwrap(),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn scoped_key_intersects_with_role() {
        let repo = InMemoryAuthzRepo::default();
        let app = AppId::new();
        // Owner key with only script:read — cannot write
        let p = Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Owner,
            scopes: Some(vec![Scope::ScriptRead]),
            app_binding: None,
        };
        assert!(can(&repo, &p, Capability::AppRead(app))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &p, Capability::AppWriteScript(app))
                .await
                .unwrap(),
            Decision::Deny
        );
        // Even though the user is owner — the key's scope set is the
        // hard ceiling.
        assert_eq!(
            can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn bound_key_cannot_escape_its_app() {
        let repo = InMemoryAuthzRepo::default();
        let bound_app = AppId::new();
        let other_app = AppId::new();
        let p = Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Owner,
            scopes: Some(vec![Scope::ScriptWrite]),
            app_binding: Some(bound_app),
        };
        assert!(can(&repo, &p, Capability::AppWriteScript(bound_app))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &p, Capability::AppWriteScript(other_app))
                .await
                .unwrap(),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn bound_key_cannot_do_instance_actions() {
        let repo = InMemoryAuthzRepo::default();
        let bound_app = AppId::new();
        let p = Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Owner,
            scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo
            app_binding: Some(bound_app),
        };
        assert_eq!(
            can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
            Decision::Deny,
            "bound key with instance scope must still be denied at the binding layer"
        );
    }

    #[tokio::test]
    async fn topic_manage_requires_app_admin() {
        let repo = InMemoryAuthzRepo::default();
        let app = AppId::new();
        // Maps to the app:admin scope, not a new one.
        assert_eq!(
            Capability::AppTopicManage(app).required_scope(),
            Scope::AppAdmin
        );

        // Member with only Editor role cannot manage topics.
        let p = principal(InstanceRole::Member);
        repo.grant(p.user_id, app, AppRole::Editor).await;
        assert_eq!(
            can(&repo, &p, Capability::AppTopicManage(app))
                .await
                .unwrap(),
            Decision::Deny,
        );

        // App-admin role can.
        let admin = principal(InstanceRole::Member);
        repo.grant(admin.user_id, app, AppRole::AppAdmin).await;
        assert!(can(&repo, &admin, Capability::AppTopicManage(app))
            .await
            .unwrap()
            .is_allow());
    }

    #[tokio::test]
    async fn app_users_admin_requires_app_admin_role() {
        let repo = InMemoryAuthzRepo::default();
        let app = AppId::new();
        // Per the seven-scope commitment, AppUsersAdmin maps to
        // script:write (no new scope) — but the per-app role chain
        // still gates it at app_admin+.
        assert_eq!(
            Capability::AppUsersAdmin(app).required_scope(),
            Scope::ScriptWrite
        );

        // Member with only Editor role cannot administer users.
        let p = principal(InstanceRole::Member);
        repo.grant(p.user_id, app, AppRole::Editor).await;
        assert_eq!(
            can(&repo, &p, Capability::AppUsersAdmin(app))
                .await
                .unwrap(),
            Decision::Deny,
        );

        // App-admin role can.
        let admin = principal(InstanceRole::Member);
        repo.grant(admin.user_id, app, AppRole::AppAdmin).await;
        assert!(can(&repo, &admin, Capability::AppUsersAdmin(app))
            .await
            .unwrap()
            .is_allow());
    }

    #[tokio::test]
    async fn app_users_read_and_write_follow_viewer_editor_chain() {
        let repo = InMemoryAuthzRepo::default();
        let app = AppId::new();
        let viewer = principal(InstanceRole::Member);
        repo.grant(viewer.user_id, app, AppRole::Viewer).await;
        assert!(can(&repo, &viewer, Capability::AppUsersRead(app))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &viewer, Capability::AppUsersWrite(app))
                .await
                .unwrap(),
            Decision::Deny,
        );

        let editor = principal(InstanceRole::Member);
        repo.grant(editor.user_id, app, AppRole::Editor).await;
        assert!(can(&repo, &editor, Capability::AppUsersWrite(app))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &editor, Capability::AppUsersAdmin(app))
                .await
                .unwrap(),
            Decision::Deny,
        );
    }

    // ------------------------------------------------------------------
    // Hierarchy-aware RBAC (Phase 2 groups)
    // ------------------------------------------------------------------

    #[tokio::test]
    async fn group_admin_on_ancestor_is_implicit_app_admin() {
        let repo = InMemoryAuthzRepo::default();
        let acme = GroupId::new();
        let app = AppId::new();
        repo.add_group(acme, None).await;
        repo.put_app(app, acme).await;

        let p = principal(InstanceRole::Member);
        // No app_members row — authority comes purely from the group.
        repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;

        for cap in [
            Capability::AppRead(app),
            Capability::AppWriteScript(app),
            Capability::AppAdmin(app),
        ] {
            assert!(
                can(&repo, &p, cap).await.unwrap().is_allow(),
                "inherited group_admin denied {cap:?}"
            );
        }
    }

    #[tokio::test]
    async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
        let repo = InMemoryAuthzRepo::default();
        let acme = GroupId::new();
        let app = AppId::new();
        repo.add_group(acme, None).await;
        repo.put_app(app, acme).await;

        let p = principal(InstanceRole::Member);
        // Direct viewer on the app, app_admin via the ancestor group:
        // the higher (app_admin) wins.
        repo.grant(p.user_id, app, AppRole::Viewer).await;
        repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;

        assert!(can(&repo, &p, Capability::AppAdmin(app))
            .await
            .unwrap()
            .is_allow());
    }

    #[tokio::test]
    async fn group_role_inherits_down_a_multi_level_tree() {
        let repo = InMemoryAuthzRepo::default();
        let root = GroupId::new();
        let team = GroupId::new();
        let app = AppId::new();
        repo.add_group(root, None).await;
        repo.add_group(team, Some(root)).await;
        repo.put_app(app, team).await;

        // Editor two levels up flows down to the app as editor.
        let p = principal(InstanceRole::Member);
        repo.grant_group(p.user_id, root, AppRole::Editor).await;

        assert!(can(&repo, &p, Capability::AppWriteScript(app))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
            Decision::Deny,
            "editor must not get app_admin"
        );
    }

    #[tokio::test]
    async fn group_membership_grants_no_instance_capabilities() {
        let repo = InMemoryAuthzRepo::default();
        let acme = GroupId::new();
        repo.add_group(acme, None).await;
        let p = principal(InstanceRole::Member);
        repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;

        for cap in [
            Capability::InstanceCreateApp,
            Capability::InstanceCreateGroup,
            Capability::InstanceManageUsers,
        ] {
            assert_eq!(
                can(&repo, &p, cap).await.unwrap(),
                Decision::Deny,
                "group_admin must not grant instance cap {cap:?}"
            );
        }
    }

    #[tokio::test]
    async fn group_admin_walks_ancestors_for_group_caps() {
        let repo = InMemoryAuthzRepo::default();
        let root = GroupId::new();
        let team = GroupId::new();
        repo.add_group(root, None).await;
        repo.add_group(team, Some(root)).await;

        let p = principal(InstanceRole::Member);
        repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;

        // group_admin at root ⇒ admin of the descendant group.
        assert!(can(&repo, &p, Capability::GroupAdmin(team))
            .await
            .unwrap()
            .is_allow());
        assert!(can(&repo, &p, Capability::GroupWrite(team))
            .await
            .unwrap()
            .is_allow());

        // An unrelated member gets nothing.
        let outsider = principal(InstanceRole::Member);
        assert_eq!(
            can(&repo, &outsider, Capability::GroupRead(team))
                .await
                .unwrap(),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn group_kv_caps_resolve_by_role_up_the_chain() {
        // §11.6: shared-KV read is viewer+, write is editor+, both resolved via
        // the group ancestor walk; an outsider gets nothing.
        let repo = InMemoryAuthzRepo::default();
        let root = GroupId::new();
        let team = GroupId::new();
        repo.add_group(root, None).await;
        repo.add_group(team, Some(root)).await;

        // A viewer at root can READ a descendant group's shared KV but not write.
        let viewer = principal(InstanceRole::Member);
        repo.grant_group(viewer.user_id, root, AppRole::Viewer)
            .await;
        assert!(can(&repo, &viewer, Capability::GroupKvRead(team))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &viewer, Capability::GroupKvWrite(team))
                .await
                .unwrap(),
            Decision::Deny
        );

        // An editor at root can WRITE it.
        let editor = principal(InstanceRole::Member);
        repo.grant_group(editor.user_id, root, AppRole::Editor)
            .await;
        assert!(can(&repo, &editor, Capability::GroupKvWrite(team))
            .await
            .unwrap()
            .is_allow());

        // An unrelated member gets neither.
        let outsider = principal(InstanceRole::Member);
        assert_eq!(
            can(&repo, &outsider, Capability::GroupKvRead(team))
                .await
                .unwrap(),
            Decision::Deny
        );

        // Group caps carry no app_id, so a bound key is denied at the binding
        // layer regardless of role.
        let bound = Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Owner,
            scopes: Some(vec![Scope::ScriptWrite]),
            app_binding: Some(AppId::new()),
        };
        assert_eq!(
            can(&repo, &bound, Capability::GroupKvWrite(team))
                .await
                .unwrap(),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn group_docs_caps_resolve_by_role_up_the_chain() {
        // §11.6 docs: same trust shape as shared KV — read is viewer+, write is
        // editor+, resolved via the group ancestor walk.
        let repo = InMemoryAuthzRepo::default();
        let root = GroupId::new();
        let team = GroupId::new();
        repo.add_group(root, None).await;
        repo.add_group(team, Some(root)).await;

        let viewer = principal(InstanceRole::Member);
        repo.grant_group(viewer.user_id, root, AppRole::Viewer)
            .await;
        assert!(can(&repo, &viewer, Capability::GroupDocsRead(team))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &viewer, Capability::GroupDocsWrite(team))
                .await
                .unwrap(),
            Decision::Deny
        );

        let editor = principal(InstanceRole::Member);
        repo.grant_group(editor.user_id, root, AppRole::Editor)
            .await;
        assert!(can(&repo, &editor, Capability::GroupDocsWrite(team))
            .await
            .unwrap()
            .is_allow());

        let outsider = principal(InstanceRole::Member);
        assert_eq!(
            can(&repo, &outsider, Capability::GroupDocsRead(team))
                .await
                .unwrap(),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn group_files_caps_resolve_by_role_up_the_chain() {
        // §11.6 files: same trust shape as shared KV/docs — read is viewer+,
        // write is editor+, resolved via the group ancestor walk.
        let repo = InMemoryAuthzRepo::default();
        let root = GroupId::new();
        let team = GroupId::new();
        repo.add_group(root, None).await;
        repo.add_group(team, Some(root)).await;

        let viewer = principal(InstanceRole::Member);
        repo.grant_group(viewer.user_id, root, AppRole::Viewer)
            .await;
        assert!(can(&repo, &viewer, Capability::GroupFilesRead(team))
            .await
            .unwrap()
            .is_allow());
        assert_eq!(
            can(&repo, &viewer, Capability::GroupFilesWrite(team))
                .await
                .unwrap(),
            Decision::Deny
        );

        let editor = principal(InstanceRole::Member);
        repo.grant_group(editor.user_id, root, AppRole::Editor)
            .await;
        assert!(can(&repo, &editor, Capability::GroupFilesWrite(team))
            .await
            .unwrap()
            .is_allow());

        let outsider = principal(InstanceRole::Member);
        assert_eq!(
            can(&repo, &outsider, Capability::GroupFilesRead(team))
                .await
                .unwrap(),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn admin_implicitly_manages_the_whole_group_tree() {
        let repo = InMemoryAuthzRepo::default();
        let g = GroupId::new();
        let p = principal(InstanceRole::Admin);
        for cap in [
            Capability::InstanceCreateGroup,
            Capability::GroupRead(g),
            Capability::GroupWrite(g),
            Capability::GroupAdmin(g),
        ] {
            assert!(
                can(&repo, &p, cap).await.unwrap().is_allow(),
                "admin denied group cap {cap:?}"
            );
        }
    }

    #[tokio::test]
    async fn bound_key_cannot_manage_groups() {
        let repo = InMemoryAuthzRepo::default();
        let g = GroupId::new();
        let p = Principal {
            user_id: AdminUserId::new(),
            instance_role: InstanceRole::Owner,
            scopes: Some(vec![Scope::AppAdmin]),
            app_binding: Some(AppId::new()),
        };
        // Group caps carry no app_id, so a bound key is denied at the
        // binding layer regardless of role/scope.
        assert_eq!(
            can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
            Decision::Deny
        );
    }

    #[test]
    fn app_role_max_is_authority_ordered() {
        assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
        assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
        assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
    }

    #[test]
    fn capability_app_id_extraction() {
        let app = AppId::new();
        assert_eq!(Capability::InstanceCreateApp.app_id(), None);
        assert_eq!(Capability::AppRead(app).app_id(), Some(app));
        assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
        // Group caps are not app-scoped.
        assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
        assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
    }

    #[test]
    fn capability_required_scope_mapping_is_complete() {
        // Sanity: every variant returns a scope. Compiler-enforced
        // exhaustiveness lives in the match itself; this test guards
        // against accidental drift to a default branch.
        let app = AppId::new();
        for cap in [
            Capability::InstanceCreateApp,
            Capability::InstanceManageUsers,
            Capability::InstanceManageSettings,
            Capability::AppRead(app),
            Capability::AppWriteScript(app),
            Capability::AppWriteRoute(app),
            Capability::AppManageDomains(app),
            Capability::AppAdmin(app),
            Capability::AppLogRead(app),
        ] {
            let _ = cap.required_scope(); // does not panic
        }
    }
}
