//! Cross-crate authn/authz types — Phase 3.5, see blueprint §11.6.
//!
//! The `Principal` extracted by `manager-core::auth_middleware` lives
//! here so handlers in every crate (and, later, the v1.1 SDKs in
//! `executor-core`) can refer to the same shape without pulling in the
//! manager crate. The authorization rules themselves live in
//! `manager-core::authz` — this module is data only.
//!
//! `UserId` is a transitional alias for `AdminUserId`. Phase 3a named
//! the table `admin_users` to leave room for the v1.1 script-level
//! `users` SDK feature (see blueprint §11.4 "Naming"); from the
//! authorization layer's perspective an admin row is the principal
//! identity, so we expose the alias rather than renaming the existing
//! id type.

use serde::{Deserialize, Serialize};

use crate::{AdminUserId, AppId};

/// Transitional alias — see module docs.
pub type UserId = AdminUserId;

/// Instance-wide role carried by every `admin_users` row. The DB
/// representation is `text` (`'owner'|'admin'|'member'`), checked via
/// a CHECK constraint in migration `0006_users_authz.sql`; this enum
/// is the Rust mirror.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstanceRole {
    /// Full instance control, manage other owners, implicit `app_admin`
    /// on every app. Multiple allowed.
    Owner,
    /// Create apps, invite users, implicit `editor` on every app. No
    /// instance-settings authority and no owner-management.
    Admin,
    /// Invited into specific apps via `app_members` only. No app
    /// creation, no invite authority. List endpoints filter strictly
    /// by membership at SQL.
    Member,
}

impl InstanceRole {
    /// Stable string form — matches the DB CHECK constraint values
    /// exactly. Used by repos and the seed/audit paths.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Owner => "owner",
            Self::Admin => "admin",
            Self::Member => "member",
        }
    }

    /// Inverse of `as_str` — used when reading a row out of Postgres.
    /// Returns `None` for unknown values so the caller can decide
    /// between failing loudly or skipping a bad row.
    #[must_use]
    pub fn from_db_str(s: &str) -> Option<Self> {
        match s {
            "owner" => Some(Self::Owner),
            "admin" => Some(Self::Admin),
            "member" => Some(Self::Member),
            _ => None,
        }
    }
}

/// Per-app role recorded in `app_members`. Members hold zero-or-one row
/// per (user, app); owners and admins are not represented in the table
/// (their app authority is implicit via `InstanceRole`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppRole {
    /// App settings, domain claims, delete.
    AppAdmin,
    /// CRUD on scripts, routes, sandbox config.
    Editor,
    /// Read scripts + execution logs.
    Viewer,
}

impl AppRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::AppAdmin => "app_admin",
            Self::Editor => "editor",
            Self::Viewer => "viewer",
        }
    }

    #[must_use]
    pub fn from_db_str(s: &str) -> Option<Self> {
        match s {
            "app_admin" => Some(Self::AppAdmin),
            "editor" => Some(Self::Editor),
            "viewer" => Some(Self::Viewer),
            _ => None,
        }
    }

    /// Authority rank: higher = more authority. Used to fold the highest
    /// effective role across an app's own membership and every ancestor
    /// group membership (hierarchy-aware RBAC). Defined explicitly rather
    /// than via a derived `Ord` because the variant declaration order
    /// (`AppAdmin` first) is the reverse of authority order.
    #[must_use]
    pub const fn precedence(self) -> u8 {
        match self {
            Self::AppAdmin => 3,
            Self::Editor => 2,
            Self::Viewer => 1,
        }
    }

    /// The more-authoritative of two roles. `app_admin > editor > viewer`.
    #[must_use]
    pub fn max(self, other: Self) -> Self {
        if self.precedence() >= other.precedence() {
            self
        } else {
            other
        }
    }
}

/// API-key scope. Exactly seven values; new scopes need a blueprint
/// edit before they're added here. Wire form is the colon-separated
/// string (`"script:read"`, etc.) — matches the `text[]` stored in
/// `api_keys.scopes` and the strings shown to operators.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Scope {
    ScriptRead,
    ScriptWrite,
    RouteWrite,
    DomainManage,
    LogRead,
    AppAdmin,
    InstanceAdmin,
}

impl Scope {
    pub const ALL: &'static [Scope] = &[
        Scope::ScriptRead,
        Scope::ScriptWrite,
        Scope::RouteWrite,
        Scope::DomainManage,
        Scope::LogRead,
        Scope::AppAdmin,
        Scope::InstanceAdmin,
    ];

    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::ScriptRead => "script:read",
            Self::ScriptWrite => "script:write",
            Self::RouteWrite => "route:write",
            Self::DomainManage => "domain:manage",
            Self::LogRead => "log:read",
            Self::AppAdmin => "app:admin",
            Self::InstanceAdmin => "instance:admin",
        }
    }

    #[must_use]
    pub fn from_wire(s: &str) -> Option<Self> {
        Self::ALL.iter().copied().find(|sc| sc.as_str() == s)
    }

    /// True for scopes that only make sense on an unbound key — bound
    /// keys (api_keys.app_id IS NOT NULL) cannot claim instance-wide
    /// authority and the mint handler rejects the combination at 422.
    #[must_use]
    pub const fn is_instance(self) -> bool {
        matches!(self, Self::InstanceAdmin)
    }
}

// Custom serde so the wire form is the colon-separated string. The
// stored DB value lives in a `text[]`, so the repo converts between
// `Vec<String>` and `Vec<Scope>` using `as_str`/`from_wire`.
impl Serialize for Scope {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for Scope {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        Self::from_wire(&s).ok_or_else(|| serde::de::Error::custom(format!("unknown scope: {s}")))
    }
}

/// Resolved caller identity. Produced by `manager-core::auth_middleware`
/// for both the cookie-session path (then `scopes`/`app_binding` are
/// `None`) and the bearer-API-key path (then both fields carry the
/// key's constraints).
///
/// The capability check in `manager-core::authz::can` intersects
/// `instance_role` with `scopes` and `app_binding` to decide whether
/// a given `Capability` is granted.
#[derive(Debug, Clone)]
pub struct Principal {
    pub user_id: UserId,
    pub instance_role: InstanceRole,
    /// `None` for cookie sessions (no scope restriction beyond the
    /// role itself); `Some` for API keys, in which case the effective
    /// authority is `role ∩ scopes`.
    pub scopes: Option<Vec<Scope>>,
    /// `Some(app)` for keys bound to a single app at mint time. Every
    /// `App*(other)` capability is denied regardless of role.
    pub app_binding: Option<AppId>,
}

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

    #[test]
    fn instance_role_round_trip() {
        for role in [
            InstanceRole::Owner,
            InstanceRole::Admin,
            InstanceRole::Member,
        ] {
            assert_eq!(InstanceRole::from_db_str(role.as_str()), Some(role));
        }
        assert_eq!(InstanceRole::from_db_str("bogus"), None);
    }

    #[test]
    fn app_role_round_trip() {
        for role in [AppRole::AppAdmin, AppRole::Editor, AppRole::Viewer] {
            assert_eq!(AppRole::from_db_str(role.as_str()), Some(role));
        }
        assert_eq!(AppRole::from_db_str("bogus"), None);
    }

    #[test]
    fn scope_round_trip_covers_all() {
        for &scope in Scope::ALL {
            assert_eq!(Scope::from_wire(scope.as_str()), Some(scope));
        }
        assert_eq!(Scope::from_wire("script:nope"), None);
    }

    #[test]
    fn scope_is_instance_flags_only_instance_admin() {
        for &scope in Scope::ALL {
            let expected = scope == Scope::InstanceAdmin;
            assert_eq!(scope.is_instance(), expected, "scope {}", scope.as_str());
        }
    }

    #[test]
    fn scope_serde_uses_wire_form() {
        let s = serde_json::to_string(&Scope::ScriptWrite).unwrap();
        assert_eq!(s, "\"script:write\"");
        let back: Scope = serde_json::from_str(&s).unwrap();
        assert_eq!(back, Scope::ScriptWrite);
        let err = serde_json::from_str::<Scope>("\"unknown\"").unwrap_err();
        assert!(err.to_string().contains("unknown scope"));
    }
}
