//! ADR-0019 Authority Pluralism — `Principal` as a first-class type.
//!
//! Pre-ratification: this module exists so downstream crates can take a
//! direct dependency on the type and round-trip its wire form. No
//! signed-event emission site uses it yet — that producer-side migration
//! lands in a later wave once ADR-0019 is `Accepted`.
//!
//! # Variants
//!
//! Per ADR-0019 §Decision, every signed event attributes one of four
//! principal variants:
//!
//! - [`Principal::Operator`] — a human operator's bearer-token identity
//!   (the historical singleton; preserved unchanged so v0.5-shaped
//!   `principal://operator/<id>` URIs round-trip byte-for-byte).
//! - [`Principal::Platform`] — the hosted control plane acting on behalf
//!   of a tenant (compaction, scheduled migration, GC of orphan residue).
//! - [`Principal::Delegate`] — a principal acting on behalf of an
//!   authorizing principal, with bounded [`AuthorityScope`]. The
//!   composition rule ("delegate scope MUST be a subset of authorizing
//!   scope") is enforced by [`Principal::compose`].
//! - [`Principal::Federated`] — an external IAM (OIDC issuer, ADO realm,
//!   GitHub org) acting as a principal via federation. The
//!   [`TrustRoot`] field is the load-bearing doctrinal marker that an
//!   external root is in play.
//!
//! # Wire form
//!
//! Two surfaces, kept synchronised:
//!
//! - The CloudEvent `source` URI (audit-readable) — see
//!   [`Principal::to_source_uri`] / [`Principal::from_source_uri`].
//! - The structured `principal` payload field (type-checkable) — derived
//!   `serde` representation with `#[serde(tag = "kind", …)]`.
//!
//! # Audit helper
//!
//! [`Principal::root_operator`] walks the authorizing chain and answers
//! the question compliance buyers have asked for since 0.3: *"did this
//! action originate from human approval at any depth?"* — see ADR-0019
//! §Verification clause 2.

use std::collections::BTreeSet;
use std::fmt;

use serde::{Deserialize, Serialize};
use thiserror::Error;

// --- ID newtypes -------------------------------------------------------------
//
// String newtypes per the crate's existing `RoleId(pub String)` idiom
// (see [`crate::types::RoleId`]). Distinct types prevent accidental
// cross-casting between an operator id, a platform id, and a delegate
// session id at the call site — the type-system gate ADR-0019
// §Verification clause 5 calls for.

macro_rules! string_id {
    ($(#[$meta:meta])* $name:ident) => {
        $(#[$meta])*
        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
        pub struct $name(pub String);

        impl $name {
            /// Borrow the underlying id string.
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                self.0.fmt(f)
            }
        }

        impl From<String> for $name {
            fn from(s: String) -> Self {
                Self(s)
            }
        }

        impl From<&str> for $name {
            fn from(s: &str) -> Self {
                Self(s.to_owned())
            }
        }
    };
}

string_id! {
    /// Opaque identifier for a human operator (`Principal::Operator`).
    ///
    /// In v0.5 producers this is whatever the bearer-token subject claim
    /// resolves to; in v1+ producers this is the operator's stable id in
    /// the authority-keys config.
    OperatorId
}

string_id! {
    /// Opaque identifier for the hosted control plane acting as a
    /// principal (`Principal::Platform`). Names a specific platform
    /// deployment (e.g. `hosted-ctrl-plane-prod`).
    PlatformId
}

string_id! {
    /// Opaque identifier for a delegate session (`Principal::Delegate`).
    /// For an LLM session via an MCP bridge, this is the bridge-issued
    /// session id (e.g. `llm-claude-session-456`).
    DelegateId
}

/// External identity as it appears at the federated trust root —
/// kept generic (string) so per-issuer parsing stays in the integration
/// layer. The `TrustRoot` variant disambiguates the grammar.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ExternalId(pub String);

impl ExternalId {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for ExternalId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

impl From<String> for ExternalId {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for ExternalId {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

// --- TrustRoot ---------------------------------------------------------------

/// Supported federation roots (ADR-0019 §Decision; ADO-IAM forcing).
///
/// The variant is the doctrinal marker that an external trust root is in
/// play; extending this set requires its own ADR per ADR-0019 §Compliance
/// "Doctrinal red-line".
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TrustRoot {
    /// Generic OIDC issuer. `issuer` is the issuer URL exactly as it
    /// appears in the IdP's discovery document (e.g.
    /// `https://login.example.com/`).
    Oidc { issuer: String },
    /// ADO (Application Delivery Organization) realm.
    Ado { realm: String },
    /// GitHub organization acting as the federation root (OIDC-backed
    /// in practice, but the doctrinal marker is distinct so admission
    /// can policy-route on org name).
    GitHub { org: String },
}

impl TrustRoot {
    /// URI-segment form used inside the `Federated` `source` URI:
    /// `oidc/<urlencoded-issuer>`, `ado/<realm>`, `github/<org>`.
    fn to_uri_segments(&self) -> String {
        match self {
            TrustRoot::Oidc { issuer } => format!("oidc/{}", percent_encode(issuer)),
            TrustRoot::Ado { realm } => format!("ado/{}", percent_encode(realm)),
            TrustRoot::GitHub { org } => format!("github/{}", percent_encode(org)),
        }
    }

    fn from_uri_segments(kind: &str, rest: &str) -> Result<Self, PrincipalParseError> {
        match kind {
            "oidc" => Ok(TrustRoot::Oidc {
                issuer: percent_decode(rest)?,
            }),
            "ado" => Ok(TrustRoot::Ado {
                realm: percent_decode(rest)?,
            }),
            "github" => Ok(TrustRoot::GitHub {
                org: percent_decode(rest)?,
            }),
            other => Err(PrincipalParseError::UnknownTrustRoot(other.to_owned())),
        }
    }
}

// --- AuthorityScope / Capability --------------------------------------------

/// A scope is a set of capabilities — the bounded authority a
/// `Principal::Delegate` may exercise relative to its authorizing
/// principal. ADR-0019 §Authority chain composition rule 1: a delegate's
/// scope MUST be a subset of the authorizing principal's effective scope.
///
/// Backed by `BTreeSet` so the canonical URI form is deterministic
/// (sorted) and round-trips byte-stably.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AuthorityScope {
    capabilities: BTreeSet<Capability>,
}

impl AuthorityScope {
    /// Empty scope — useful for `Operator`/`Platform`/`Federated`
    /// principals (their "effective scope" for composition purposes is
    /// "all known capabilities", represented by [`AuthorityScope::root`]).
    pub fn empty() -> Self {
        Self {
            capabilities: BTreeSet::new(),
        }
    }

    /// Scope containing every known capability. The effective scope a
    /// non-`Delegate` principal exposes to a downstream delegate per
    /// ADR-0019 §Authority chain composition rule 1.
    pub fn root() -> Self {
        Self {
            capabilities: BTreeSet::from([Capability::ToolWildcard]),
        }
    }

    /// Build a scope from an explicit capability list.
    pub fn from_capabilities<I: IntoIterator<Item = Capability>>(caps: I) -> Self {
        Self {
            capabilities: caps.into_iter().collect(),
        }
    }

    pub fn contains(&self, cap: &Capability) -> bool {
        self.capabilities.contains(cap)
            || (self.capabilities.contains(&Capability::ToolWildcard) && cap.is_tool())
    }

    /// Returns `true` iff every capability in `other` is reachable from
    /// `self` — `other ⊆ self`. The composition rule predicate.
    pub fn is_superset_of(&self, other: &AuthorityScope) -> bool {
        other.capabilities.iter().all(|c| self.contains(c))
    }

    pub fn iter(&self) -> impl Iterator<Item = &Capability> {
        self.capabilities.iter()
    }

    pub fn is_empty(&self) -> bool {
        self.capabilities.is_empty()
    }

    pub fn len(&self) -> usize {
        self.capabilities.len()
    }
}

/// Capabilities a `Delegate` may carry. The initial vocabulary is the
/// MCP-tool surface ADR-0019 names; concrete bridge ADRs (MCP, hosted
/// control plane) extend this as their scope vocabularies are ratified.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Capability {
    ToolApply,
    ToolGet,
    ToolLogs,
    ToolEvents,
    /// `tool::*` — matches any `tool::*` capability under [`AuthorityScope::contains`].
    ToolWildcard,
}

impl Capability {
    /// String form used in the `?scope=` query of a principal URI and
    /// in the serde representation.
    pub fn as_token(&self) -> &'static str {
        match self {
            Capability::ToolApply => "tool:apply",
            Capability::ToolGet => "tool:get",
            Capability::ToolLogs => "tool:logs",
            Capability::ToolEvents => "tool:events",
            Capability::ToolWildcard => "tool:*",
        }
    }

    pub fn from_token(token: &str) -> Result<Self, PrincipalParseError> {
        match token {
            "tool:apply" => Ok(Capability::ToolApply),
            "tool:get" => Ok(Capability::ToolGet),
            "tool:logs" => Ok(Capability::ToolLogs),
            "tool:events" => Ok(Capability::ToolEvents),
            "tool:*" => Ok(Capability::ToolWildcard),
            other => Err(PrincipalParseError::UnknownCapability(other.to_owned())),
        }
    }

    /// Every initial-vocabulary capability is a `tool::*` capability;
    /// this hook exists so [`AuthorityScope::contains`] can match
    /// `ToolWildcard` against future non-tool capabilities once a
    /// future ADR introduces them.
    fn is_tool(&self) -> bool {
        matches!(
            self,
            Capability::ToolApply
                | Capability::ToolGet
                | Capability::ToolLogs
                | Capability::ToolEvents
                | Capability::ToolWildcard
        )
    }
}

impl Serialize for Capability {
    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
        ser.serialize_str(self.as_token())
    }
}

impl<'de> Deserialize<'de> for Capability {
    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
        let s = String::deserialize(de)?;
        Capability::from_token(&s).map_err(serde::de::Error::custom)
    }
}

impl fmt::Display for Capability {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_token())
    }
}

// --- Errors ------------------------------------------------------------------

/// Returned by [`Principal::compose`] when the requested delegate scope
/// is not a subset of the authorizing principal's effective scope —
/// ADR-0019 §Authority chain composition rule 1, named admission
/// discriminant `delegate_scope_not_narrowing`.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error(
    "delegate scope not narrowing: requested capability `{missing}` \
     not held by authorizing principal"
)]
pub struct AuthorityScopeViolation {
    /// The first capability found in `requested_scope` that the
    /// authorizing principal does not hold. Surfaced verbatim so the
    /// admission-side log line names the offending token.
    pub missing: Capability,
}

/// Returned by [`Principal::from_source_uri`] when the URI does not parse
/// as a principal chain — the named admission discriminant
/// `unrecognized_principal_variant` from ADR-0019 §Verification.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PrincipalParseError {
    #[error("principal URI must start with `principal://`, got `{0}`")]
    MissingScheme(String),
    #[error("principal URI has no variant segment: `{0}`")]
    EmptyPath(String),
    #[error("unknown principal kind `{0}` (expected operator|platform|delegate|federated)")]
    UnknownKind(String),
    #[error("malformed `{kind}` principal URI: {reason}")]
    Malformed { kind: &'static str, reason: String },
    #[error("unknown trust root `{0}` (expected oidc|ado|github)")]
    UnknownTrustRoot(String),
    #[error("unknown capability token `{0}`")]
    UnknownCapability(String),
    #[error("invalid percent-encoding in URI segment: `{0}`")]
    BadPercentEncoding(String),
}

// --- Principal ---------------------------------------------------------------

/// A first-class principal — the entity acting in CellOS authority chains.
///
/// Per ADR-0019 §Decision, every signed event attributes one of these
/// four variants. Composition (`Delegate.scope ⊆ authorizing.scope`) is
/// enforced by [`Principal::compose`].
///
/// # Wire form
///
/// The structured representation uses an internally-tagged enum with
/// `kind` as the discriminant — see the module-level docs for the URI
/// form. Round-trip is guaranteed:
/// `Principal::from_source_uri(p.to_source_uri()) == Ok(p)` for every
/// valid principal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Principal {
    /// The historical case: a human operator's bearer-token identity.
    /// Preserved as the v0.5 wire form so existing consumers round-trip
    /// byte-for-byte.
    Operator { id: OperatorId },

    /// The hosted control plane itself acting on behalf of a tenant.
    /// Used for periodic compaction, tenant migration, billing snapshots.
    Platform { id: PlatformId },

    /// An LLM session / programmatic agent acting on behalf of an
    /// authorizing principal, with bounded scope. Use
    /// [`Principal::compose`] to construct — the constructor enforces
    /// the narrowing invariant.
    Delegate {
        authorizing: Box<Principal>,
        delegate: DelegateId,
        scope: AuthorityScope,
    },

    /// An external IAM (OIDC issuer, ADO org, GitHub org) acting as a
    /// principal via federation.
    Federated {
        trust_root: TrustRoot,
        identity: ExternalId,
    },
}

impl Principal {
    /// Returns `Some(operator_id)` iff the principal chain bottoms out
    /// at a human [`Principal::Operator`] at any depth; `None`
    /// otherwise. Used by compliance queries to answer ADR-0019's
    /// "did a human author this action at any depth?" question.
    pub fn root_operator(&self) -> Option<&OperatorId> {
        match self {
            Principal::Operator { id } => Some(id),
            Principal::Delegate { authorizing, .. } => authorizing.root_operator(),
            Principal::Platform { .. } | Principal::Federated { .. } => None,
        }
    }

    /// The effective scope a principal exposes to a downstream delegate.
    ///
    /// - A `Delegate` exposes its own (already-narrowed) `scope`.
    /// - Any other variant is treated as holding the root scope
    ///   ([`AuthorityScope::root`]) for the purpose of composition.
    ///   ADR-0019 §Out-of-scope leaves "what specific capabilities a
    ///   non-delegate principal holds" to the tenancy and federated-
    ///   authority ADRs; pre-ratification we treat the root as
    ///   unbounded so composition does not block legitimate first
    ///   delegations.
    pub fn effective_scope(&self) -> AuthorityScope {
        match self {
            Principal::Delegate { scope, .. } => scope.clone(),
            _ => AuthorityScope::root(),
        }
    }

    /// Compose a delegate principal. Returns
    /// `Ok(Principal::Delegate { … })` iff `requested_scope` is a subset
    /// of `authorizing.effective_scope()`. Otherwise returns
    /// [`AuthorityScopeViolation`] naming the first non-narrowing
    /// capability — the admission discriminant
    /// `delegate_scope_not_narrowing`.
    pub fn compose(
        authorizing: Principal,
        delegate: DelegateId,
        requested_scope: AuthorityScope,
    ) -> Result<Principal, AuthorityScopeViolation> {
        let upstream = authorizing.effective_scope();
        if let Some(missing) = requested_scope.iter().find(|cap| !upstream.contains(cap)) {
            return Err(AuthorityScopeViolation {
                missing: (*missing).clone(),
            });
        }
        Ok(Principal::Delegate {
            authorizing: Box::new(authorizing),
            delegate,
            scope: requested_scope,
        })
    }

    /// Render the CloudEvent `source` URI representation per ADR-0019:
    ///
    /// - Operator: `principal://operator/<id>`
    /// - Platform: `principal://platform/<id>`
    /// - Delegate: `principal://<chain>/delegate/<id>?scope=<csv>`
    ///   where `<chain>` is the authorizing principal's URI body
    ///   (everything after `principal://`, query stripped).
    /// - Federated: `principal://federated/<root_kind>/<root_id>/identity/<external_id>`
    ///
    /// The `?scope=` query carries capabilities in sorted-token form
    /// (the [`AuthorityScope`] backing `BTreeSet` orders them), comma-
    /// separated, so the URI is canonical and `to_source_uri ∘ from_source_uri`
    /// is a deterministic identity on valid principals.
    pub fn to_source_uri(&self) -> String {
        format!("principal://{}", self.uri_body_with_query())
    }

    /// `body[?query]` form. Used internally so `Delegate` can embed its
    /// authorizing principal's body without the `principal://` scheme.
    fn uri_body_with_query(&self) -> String {
        match self {
            Principal::Operator { id } => format!("operator/{}", percent_encode(id.as_str())),
            Principal::Platform { id } => format!("platform/{}", percent_encode(id.as_str())),
            Principal::Federated {
                trust_root,
                identity,
            } => format!(
                "federated/{}/identity/{}",
                trust_root.to_uri_segments(),
                percent_encode(identity.as_str())
            ),
            Principal::Delegate {
                authorizing,
                delegate,
                scope,
            } => {
                let (auth_body, auth_query) = authorizing.uri_split();
                let mut body = format!(
                    "{}/delegate/{}",
                    auth_body,
                    percent_encode(delegate.as_str())
                );
                // Merge the authorizing chain's existing query (if any)
                // with this delegate's scope. Authorizing query first
                // (deeper-chain scopes appear earlier — read top-down).
                let scope_query = if scope.is_empty() {
                    String::new()
                } else {
                    let tokens: Vec<&str> = scope.iter().map(|c| c.as_token()).collect();
                    format!("scope={}", tokens.join(","))
                };
                let merged = match (auth_query.is_empty(), scope_query.is_empty()) {
                    (true, true) => String::new(),
                    (false, true) => auth_query,
                    (true, false) => scope_query,
                    (false, false) => format!("{}&{}", auth_query, scope_query),
                };
                if merged.is_empty() {
                    body
                } else {
                    body.push('?');
                    body.push_str(&merged);
                    body
                }
            }
        }
    }

    /// Returns `(body_without_query, query_without_leading_qmark)`.
    fn uri_split(&self) -> (String, String) {
        let full = self.uri_body_with_query();
        match full.find('?') {
            Some(i) => (full[..i].to_owned(), full[i + 1..].to_owned()),
            None => (full, String::new()),
        }
    }

    /// Parse a CloudEvent `source` URI back into a `Principal`. Returns
    /// [`PrincipalParseError`] for any input that does not match the
    /// grammar in [`Principal::to_source_uri`].
    pub fn from_source_uri(uri: &str) -> Result<Principal, PrincipalParseError> {
        let rest = uri
            .strip_prefix("principal://")
            .ok_or_else(|| PrincipalParseError::MissingScheme(uri.to_owned()))?;
        if rest.is_empty() {
            return Err(PrincipalParseError::EmptyPath(uri.to_owned()));
        }

        // Split off the global query (only `scope=` is defined today).
        let (path, query) = match rest.find('?') {
            Some(i) => (&rest[..i], &rest[i + 1..]),
            None => (rest, ""),
        };

        // Parse out every `scope=…` clause in left-to-right order —
        // the outermost (deepest-chain) clause comes first, matching
        // the merge order in `uri_body_with_query`.
        let mut scope_clauses: Vec<AuthorityScope> = Vec::new();
        if !query.is_empty() {
            for clause in query.split('&') {
                let (k, v) =
                    clause
                        .split_once('=')
                        .ok_or_else(|| PrincipalParseError::Malformed {
                            kind: "query",
                            reason: format!("clause `{}` missing `=`", clause),
                        })?;
                if k != "scope" {
                    return Err(PrincipalParseError::Malformed {
                        kind: "query",
                        reason: format!("unknown query parameter `{}`", k),
                    });
                }
                let caps: Result<BTreeSet<Capability>, _> = v
                    .split(',')
                    .filter(|s| !s.is_empty())
                    .map(Capability::from_token)
                    .collect();
                scope_clauses.push(AuthorityScope {
                    capabilities: caps?,
                });
            }
        }

        let segments: Vec<&str> = path.split('/').collect();
        Self::parse_segments(&segments, &mut scope_clauses.into_iter())
    }

    /// Recursive descent over `<kind>/<id>[/delegate/<id>…]` segments,
    /// pulling scope clauses off `scopes_left_to_right` in order so the
    /// innermost (rightmost-in-URI) delegate gets the last clause.
    fn parse_segments(
        segments: &[&str],
        scopes: &mut std::vec::IntoIter<AuthorityScope>,
    ) -> Result<Principal, PrincipalParseError> {
        if segments.is_empty() {
            return Err(PrincipalParseError::Malformed {
                kind: "principal",
                reason: "no segments".to_owned(),
            });
        }
        match segments[0] {
            "operator" => {
                if segments.len() < 2 {
                    return Err(PrincipalParseError::Malformed {
                        kind: "operator",
                        reason: "missing id segment".to_owned(),
                    });
                }
                let id = percent_decode(segments[1])?;
                // The operator must be the tail of its sub-chain — if
                // anything follows it must be `/delegate/…`, handled by
                // the caller; here we only own segments[0..=1].
                if segments.len() > 2 && segments[2] != "delegate" {
                    return Err(PrincipalParseError::Malformed {
                        kind: "operator",
                        reason: format!("unexpected segment `{}` after operator id", segments[2]),
                    });
                }
                let principal = Principal::Operator { id: OperatorId(id) };
                Self::wrap_delegates(principal, &segments[2..], scopes)
            }
            "platform" => {
                if segments.len() < 2 {
                    return Err(PrincipalParseError::Malformed {
                        kind: "platform",
                        reason: "missing id segment".to_owned(),
                    });
                }
                let id = percent_decode(segments[1])?;
                if segments.len() > 2 && segments[2] != "delegate" {
                    return Err(PrincipalParseError::Malformed {
                        kind: "platform",
                        reason: format!("unexpected segment `{}` after platform id", segments[2]),
                    });
                }
                let principal = Principal::Platform { id: PlatformId(id) };
                Self::wrap_delegates(principal, &segments[2..], scopes)
            }
            "federated" => {
                // `federated/<root_kind>/<root_id>/identity/<external_id>[/delegate/…]`
                if segments.len() < 5 || segments[3] != "identity" {
                    return Err(PrincipalParseError::Malformed {
                        kind: "federated",
                        reason: format!(
                            "expected `federated/<root_kind>/<root_id>/identity/<external_id>`, got `{}`",
                            segments.join("/")
                        ),
                    });
                }
                let trust_root = TrustRoot::from_uri_segments(segments[1], segments[2])?;
                let identity = ExternalId(percent_decode(segments[4])?);
                if segments.len() > 5 && segments[5] != "delegate" {
                    return Err(PrincipalParseError::Malformed {
                        kind: "federated",
                        reason: format!(
                            "unexpected segment `{}` after federated identity",
                            segments[5]
                        ),
                    });
                }
                let principal = Principal::Federated {
                    trust_root,
                    identity,
                };
                Self::wrap_delegates(principal, &segments[5..], scopes)
            }
            other => Err(PrincipalParseError::UnknownKind(other.to_owned())),
        }
    }

    /// Wrap an authorizing principal in zero or more `Delegate` layers
    /// drawn from the remaining `delegate/<id>` segment pairs, pulling
    /// one `AuthorityScope` clause per layer from `scopes` (left-to-right
    /// in the URI = outermost-to-innermost in the chain).
    fn wrap_delegates(
        mut principal: Principal,
        mut remaining: &[&str],
        scopes: &mut std::vec::IntoIter<AuthorityScope>,
    ) -> Result<Principal, PrincipalParseError> {
        while !remaining.is_empty() {
            if remaining[0] != "delegate" {
                return Err(PrincipalParseError::Malformed {
                    kind: "delegate",
                    reason: format!("expected `delegate`, got `{}`", remaining[0]),
                });
            }
            if remaining.len() < 2 {
                return Err(PrincipalParseError::Malformed {
                    kind: "delegate",
                    reason: "missing delegate id segment".to_owned(),
                });
            }
            let delegate_id = DelegateId(percent_decode(remaining[1])?);
            let scope = scopes.next().unwrap_or_else(AuthorityScope::empty);
            principal = Principal::Delegate {
                authorizing: Box::new(principal),
                delegate: delegate_id,
                scope,
            };
            remaining = &remaining[2..];
        }
        Ok(principal)
    }
}

// --- URI helpers -------------------------------------------------------------

/// Minimal percent-encoder for a single URI path segment. Encodes every
/// byte except the unreserved set (RFC 3986 §2.3:
/// `A-Z a-z 0-9 - . _ ~`). No external dep — keeps `cellos-core`'s
/// dependency surface unchanged.
fn percent_encode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for &b in s.as_bytes() {
        let ok = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
        if ok {
            out.push(b as char);
        } else {
            out.push('%');
            out.push_str(&format!("{:02X}", b));
        }
    }
    out
}

/// Inverse of [`percent_encode`]. Rejects invalid percent triplets so
/// admission sees a typed parse error rather than a silent lossy decode.
fn percent_decode(s: &str) -> Result<String, PrincipalParseError> {
    let bytes = s.as_bytes();
    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' {
            if i + 2 >= bytes.len() {
                return Err(PrincipalParseError::BadPercentEncoding(s.to_owned()));
            }
            let hi = hex_nibble(bytes[i + 1])
                .ok_or_else(|| PrincipalParseError::BadPercentEncoding(s.to_owned()))?;
            let lo = hex_nibble(bytes[i + 2])
                .ok_or_else(|| PrincipalParseError::BadPercentEncoding(s.to_owned()))?;
            out.push((hi << 4) | lo);
            i += 3;
        } else {
            out.push(bytes[i]);
            i += 1;
        }
    }
    String::from_utf8(out).map_err(|_| PrincipalParseError::BadPercentEncoding(s.to_owned()))
}

fn hex_nibble(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(10 + b - b'a'),
        b'A'..=b'F' => Some(10 + b - b'A'),
        _ => None,
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;

    fn op(id: &str) -> Principal {
        Principal::Operator {
            id: OperatorId(id.to_owned()),
        }
    }

    fn plat(id: &str) -> Principal {
        Principal::Platform {
            id: PlatformId(id.to_owned()),
        }
    }

    fn fed_ado(realm: &str, ext: &str) -> Principal {
        Principal::Federated {
            trust_root: TrustRoot::Ado {
                realm: realm.to_owned(),
            },
            identity: ExternalId(ext.to_owned()),
        }
    }

    #[test]
    fn operator_round_trip() {
        let p = op("op-123");
        let uri = p.to_source_uri();
        assert_eq!(uri, "principal://operator/op-123");
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
    }

    #[test]
    fn platform_round_trip() {
        let p = plat("hosted-ctrl-plane-prod");
        let uri = p.to_source_uri();
        assert_eq!(uri, "principal://platform/hosted-ctrl-plane-prod");
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
    }

    #[test]
    fn federated_round_trip() {
        let p = fed_ado("realm-acme", "user-7842");
        let uri = p.to_source_uri();
        assert_eq!(
            uri,
            "principal://federated/ado/realm-acme/identity/user-7842"
        );
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
    }

    #[test]
    fn federated_oidc_round_trip_with_url_issuer() {
        let p = Principal::Federated {
            trust_root: TrustRoot::Oidc {
                issuer: "https://login.example.com/".to_owned(),
            },
            identity: ExternalId("user-42".to_owned()),
        };
        let uri = p.to_source_uri();
        // The issuer URL is percent-encoded (slashes, colon).
        assert!(uri.starts_with("principal://federated/oidc/"));
        assert!(uri.contains("/identity/user-42"));
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
    }

    #[test]
    fn federated_github_round_trip() {
        let p = Principal::Federated {
            trust_root: TrustRoot::GitHub {
                org: "anthropic".to_owned(),
            },
            identity: ExternalId("octocat".to_owned()),
        };
        let uri = p.to_source_uri();
        assert_eq!(
            uri,
            "principal://federated/github/anthropic/identity/octocat"
        );
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
    }

    #[test]
    fn delegate_round_trip_one_level() {
        let scope = AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]);
        let p = Principal::compose(op("op-123"), DelegateId("llm-claude-456".to_owned()), scope)
            .unwrap();
        let uri = p.to_source_uri();
        assert_eq!(
            uri,
            "principal://operator/op-123/delegate/llm-claude-456?scope=tool:apply,tool:get"
        );
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
    }

    #[test]
    fn delegate_round_trip_two_levels() {
        let outer = Principal::compose(
            op("op-123"),
            DelegateId("bridge-A".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]),
        )
        .unwrap();
        let inner = Principal::compose(
            outer,
            DelegateId("bridge-B".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolGet]),
        )
        .unwrap();
        let uri = inner.to_source_uri();
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), inner);
    }

    #[test]
    fn delegate_with_empty_scope_round_trips() {
        // An empty scope is degenerate but legal — round-trip must not
        // append a `?scope=` clause and must not synthesise one on parse.
        let p = Principal::compose(
            op("op-123"),
            DelegateId("noop-bridge".to_owned()),
            AuthorityScope::empty(),
        )
        .unwrap();
        let uri = p.to_source_uri();
        assert_eq!(uri, "principal://operator/op-123/delegate/noop-bridge");
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
    }

    #[test]
    fn compose_narrows_scope_returns_ok() {
        // Authorizing scope is unbounded (root), so any tool capability narrows fine.
        let p = Principal::compose(
            op("op-123"),
            DelegateId("session-1".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolApply]),
        );
        assert!(p.is_ok());
    }

    #[test]
    fn compose_broadens_scope_returns_err() {
        // Authorizing principal is already a Delegate with a narrow scope;
        // requesting a broader capability must fail with the missing token named.
        let narrow = Principal::compose(
            op("op-123"),
            DelegateId("session-A".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolGet]),
        )
        .unwrap();
        let err = Principal::compose(
            narrow,
            DelegateId("session-B".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolApply]),
        )
        .unwrap_err();
        assert_eq!(err.missing, Capability::ToolApply);
    }

    #[test]
    fn compose_wildcard_authorizes_specific_tool() {
        let wide = Principal::compose(
            op("op-123"),
            DelegateId("session-wild".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolWildcard]),
        )
        .unwrap();
        let narrow = Principal::compose(
            wide,
            DelegateId("session-narrow".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolEvents]),
        );
        assert!(narrow.is_ok());
    }

    #[test]
    fn root_operator_finds_human_at_depth() {
        let inner = Principal::compose(
            Principal::compose(
                op("op-human"),
                DelegateId("d1".to_owned()),
                AuthorityScope::from_capabilities([Capability::ToolApply]),
            )
            .unwrap(),
            DelegateId("d2".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolApply]),
        )
        .unwrap();
        assert_eq!(
            inner.root_operator(),
            Some(&OperatorId("op-human".to_owned()))
        );
    }

    #[test]
    fn root_operator_returns_none_for_platform() {
        assert_eq!(plat("hosted-ctrl-plane-prod").root_operator(), None);
    }

    #[test]
    fn root_operator_returns_none_for_federated() {
        assert_eq!(fed_ado("realm-acme", "user-7842").root_operator(), None);
    }

    #[test]
    fn root_operator_returns_none_for_platform_rooted_delegate() {
        // Platform-rooted chain — `root_operator` must walk through
        // Delegate boxes and report None when the bottom is non-operator.
        let p = Principal::compose(
            plat("hosted-ctrl-plane-prod"),
            DelegateId("compactor".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolApply]),
        )
        .unwrap();
        assert_eq!(p.root_operator(), None);
    }

    #[test]
    fn serde_json_round_trip_operator() {
        let p = op("op-123");
        let json = serde_json::to_string(&p).unwrap();
        let back: Principal = serde_json::from_str(&json).unwrap();
        assert_eq!(back, p);
    }

    #[test]
    fn serde_json_round_trip_delegate() {
        let p = Principal::compose(
            op("op-123"),
            DelegateId("session-1".to_owned()),
            AuthorityScope::from_capabilities([Capability::ToolApply, Capability::ToolGet]),
        )
        .unwrap();
        let json = serde_json::to_string(&p).unwrap();
        let back: Principal = serde_json::from_str(&json).unwrap();
        assert_eq!(back, p);
    }

    #[test]
    fn from_source_uri_rejects_missing_scheme() {
        let err = Principal::from_source_uri("http://operator/op-123").unwrap_err();
        assert!(matches!(err, PrincipalParseError::MissingScheme(_)));
    }

    #[test]
    fn from_source_uri_rejects_unknown_kind() {
        let err = Principal::from_source_uri("principal://martian/op-123").unwrap_err();
        assert!(matches!(err, PrincipalParseError::UnknownKind(_)));
    }

    #[test]
    fn from_source_uri_rejects_unknown_capability() {
        let err =
            Principal::from_source_uri("principal://operator/op-123/delegate/d?scope=tool:explode")
                .unwrap_err();
        assert!(matches!(err, PrincipalParseError::UnknownCapability(_)));
    }

    #[test]
    fn percent_encoding_round_trips_slash_in_id() {
        // Slashes and other reserved characters must survive round-trip
        // because real-world ids (e.g. OIDC subs with paths) contain them.
        let p = Principal::Operator {
            id: OperatorId("ns/op with space".to_owned()),
        };
        let uri = p.to_source_uri();
        assert_eq!(Principal::from_source_uri(&uri).unwrap(), p);
    }
}
