//! Casbin-based authorization layer.
//!
//! Authentication stays with Loco's JWT cookie (see [`crate::shared::guard`]);
//! Casbin only answers "is this subject allowed on this path?". At request time
//! [`inject_subject`] resolves the caller's role from the JWT and stamps it onto
//! the request as `CasbinVals`, then [`CasbinAxumLayer`] enforces the policy in
//! `config/casbin/{model.conf,policy.csv}`.
//!
//! The model is deny-override: everything is allowed by default and the policy
//! only *denies* non-admins under `/admin/*`. The per-handler
//! [`guard::current_admin`] checks stay in place as defense in depth.
//!
//! DECISION — single hardcoded admin: there is intentionally no stored `role`.
//! The one admin is the user whose email matches `ADMIN_EMAIL` in `.env`
//! (granted in `admin_seeder`, detected by [`guard::is_admin`]); every other
//! authenticated user is `customer` and the rest are `anonymous`. This is a
//! deliberate fit for the current scale, not a constraint of the design.
//!
//! Upgrade path when more roles are needed (each step is localized): add a
//! `role` column to `users` (migration), set it on register/seed, read
//! `user.role` in [`inject_subject`] instead of the `is_admin` check below, and
//! add `p`/`g` lines to `config/casbin/policy.csv`. The enforcement layer,
//! `after_routes` wiring, and tests are unaffected by that change.

use axum::{
    extract::{Request, State},
    middleware::Next,
    response::Response,
};
use axum_casbin::{
    casbin::{DefaultModel, FileAdapter},
    CasbinAxumLayer, CasbinVals,
};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;

use crate::shared::guard;

const MODEL_PATH: &str = "config/casbin/model.conf";
const POLICY_PATH: &str = "config/casbin/policy.csv";

/// Build the Casbin enforcement layer from the on-disk model + policy.
pub async fn layer() -> Result<CasbinAxumLayer> {
    let model = DefaultModel::from_file(MODEL_PATH)
        .await
        .map_err(|e| Error::Message(format!("casbin model load failed: {e}")))?;
    let adapter = FileAdapter::new(POLICY_PATH);
    CasbinAxumLayer::new(model, adapter)
        .await
        .map_err(|e| Error::Message(format!("casbin enforcer init failed: {e}")))
}

/// Resolve the caller's role from the Loco JWT cookie and attach it as the
/// Casbin subject. Always sets *some* subject (anonymous requests included) so
/// the enforcer never sees an empty value.
pub async fn inject_subject(
    State(ctx): State<AppContext>,
    jar: CookieJar,
    mut req: Request,
    next: Next,
) -> Response {
    let subject = match guard::current_user(&ctx, &jar).await {
        Some(user) if guard::is_admin(&ctx, &user) => "admin",
        Some(_) => "customer",
        None => "anonymous",
    };
    req.extensions_mut().insert(CasbinVals {
        subject: subject.to_string(),
        domain: None,
    });
    next.run(req).await
}

#[cfg(test)]
mod tests {
    use axum_casbin::casbin::{CoreApi, DefaultModel, Enforcer, FileAdapter};

    async fn enforcer() -> Enforcer {
        let model = DefaultModel::from_file(super::MODEL_PATH).await.unwrap();
        let adapter = FileAdapter::new(super::POLICY_PATH);
        Enforcer::new(model, adapter).await.unwrap()
    }

    async fn allowed(e: &Enforcer, sub: &str, obj: &str, act: &str) -> bool {
        e.enforce((sub, obj, act)).unwrap()
    }

    #[tokio::test]
    async fn admin_subtree_is_admin_only() {
        let e = enforcer().await;

        // Admins reach the protected subtree (any method, nested paths).
        assert!(allowed(&e, "admin", "/admin/dashboard", "GET").await);
        assert!(allowed(&e, "admin", "/admin/orders/5", "POST").await);

        // Customers and anonymous are denied there.
        assert!(!allowed(&e, "customer", "/admin/dashboard", "GET").await);
        assert!(!allowed(&e, "customer", "/admin/orders/5", "POST").await);
        assert!(!allowed(&e, "anonymous", "/admin/products", "GET").await);
    }

    #[tokio::test]
    async fn admin_only_endpoints_outside_admin_subtree() {
        let e = enforcer().await;

        // Admin JSON API and image upload are admin-only.
        assert!(allowed(&e, "admin", "/api/admin/dashboard", "GET").await);
        assert!(allowed(&e, "admin", "/images/upload", "POST").await);
        assert!(!allowed(&e, "customer", "/api/admin/dashboard", "GET").await);
        assert!(!allowed(&e, "anonymous", "/images/upload", "POST").await);

        // Public image serving stays open for everyone.
        assert!(allowed(&e, "anonymous", "/images/logo.png", "GET").await);
        assert!(allowed(&e, "customer", "/images/logo.png", "GET").await);
    }

    #[tokio::test]
    async fn storefront_is_open_to_everyone() {
        let e = enforcer().await;

        for sub in ["admin", "customer", "anonymous"] {
            assert!(allowed(&e, sub, "/", "GET").await);
            assert!(allowed(&e, sub, "/shop", "GET").await);
            assert!(allowed(&e, sub, "/login", "POST").await);
            // The bare /admin entry stays open so it can redirect to /login.
            assert!(allowed(&e, sub, "/admin", "GET").await);
        }
    }
}
