//! User identity, roles, and the capability policy.
//!
//! `Role` is persisted; `Capability` is the vocabulary of guarded actions. The
//! role→capability mapping (`Role::allows`) is the single source of authorization
//! policy — pure and unit-tested. Password hashes live only at the `db`/`auth`
//! boundary, never in these types.

use serde::{Deserialize, Serialize};

use crate::UserId;

/// A validated email address (normalized to lowercase, trimmed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Email(String);

/// The supplied string is not a syntactically acceptable email.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EmailError;

impl std::fmt::Display for EmailError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("invalid email address")
    }
}

impl std::error::Error for EmailError {}

impl Email {
    /// Parse and normalize an email. Light MVP validation: a single `@`, non-empty
    /// local part, a dotted non-edge domain, and no whitespace. (Fuller RFC 5321
    /// validation is deferred.)
    pub fn parse(raw: &str) -> Result<Email, EmailError> {
        let normalized = raw.trim().to_lowercase();

        if normalized.contains(char::is_whitespace) {
            return Err(EmailError);
        }

        let mut parts = normalized.split('@');
        let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
            return Err(EmailError);
        };

        let domain_ok = domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.');

        if local.is_empty() || !domain_ok {
            return Err(EmailError);
        }

        Ok(Email(normalized))
    }

    /// The normalized string.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Reconstruct from a stored (already-validated) value, without re-validating.
    /// For reading values back from the database only — never to construct an `Email`
    /// destined to be written (writes must go through [`Email::parse`] so storage
    /// stays normalized).
    pub fn from_db(value: String) -> Email {
        Email(value)
    }
}

/// A user's role within the organization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    /// Full access, including user management.
    Admin,
    /// Catalogue work: create/edit/publish records; cannot manage users.
    Editor,
}

impl Role {
    pub const fn as_str(&self) -> &'static str {
        match self {
            Role::Admin => "admin",
            Role::Editor => "editor",
        }
    }

    pub fn from_db(s: &str) -> Option<Self> {
        match s {
            "admin" => Some(Role::Admin),
            "editor" => Some(Role::Editor),
            _ => None,
        }
    }

    /// The authorization policy: whether this role may perform `capability`.
    ///
    /// The `Editor` arm is an exhaustive `match` on purpose: adding a new
    /// [`Capability`] variant is a compile error here until its Editor access is
    /// decided explicitly, so the policy fails closed rather than silently granting
    /// new capabilities to Editors.
    pub fn allows(self, capability: Capability) -> bool {
        match self {
            Role::Admin => true,
            Role::Editor => match capability {
                Capability::EditCatalogue
                | Capability::PublishObjects
                | Capability::ViewInternal => true,
                Capability::ManageUsers => false,
            },
        }
    }
}

/// A guarded action. `Authorized<C>` (in the `auth` crate) gates a handler on one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
    /// Create/list/modify users.
    ManageUsers,
    /// Create and edit catalogue records.
    EditCatalogue,
    /// Change a record's visibility (publish/unpublish).
    PublishObjects,
    /// Read internal (non-public) records.
    ViewInternal,
}

/// A user as read back from storage. Carries no password material.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
    pub id: UserId,
    pub email: Email,
    pub role: Role,
}

/// A new user to persist. `password_hash` is an argon2id PHC string (produced by `auth`).
#[derive(Debug, Clone)]
pub struct NewUser {
    pub email: Email,
    pub password_hash: String,
    pub role: Role,
}

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

    #[test]
    fn email_parses_and_normalizes() {
        assert_eq!(
            Email::parse("  Anna@Example.COM ").unwrap().as_str(),
            "anna@example.com"
        );
    }

    #[test]
    fn email_rejects_garbage() {
        for bad in [
            "",
            "no-at",
            "a@b",
            "a@@b.com",
            "a b@c.com",
            "@example.com",
            "x@.com",
            "x@com.",
        ] {
            assert!(Email::parse(bad).is_err(), "should reject {bad:?}");
        }
    }

    #[test]
    fn role_round_trips() {
        for r in [Role::Admin, Role::Editor] {
            assert_eq!(Role::from_db(r.as_str()), Some(r));
        }
        assert_eq!(Role::from_db("superuser"), None);
    }

    #[test]
    fn capability_policy_matrix() {
        use Capability::*;
        for cap in [ManageUsers, EditCatalogue, PublishObjects, ViewInternal] {
            assert!(Role::Admin.allows(cap));
        }
        assert!(!Role::Editor.allows(ManageUsers));
        for cap in [EditCatalogue, PublishObjects, ViewInternal] {
            assert!(Role::Editor.allows(cap));
        }
    }
}
