//! Admin HTTP surface for the declarative reconcile engine.
//!
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
//! against the app's live state and return the plan. Read-only; requires
//! `AppRead`. The `apply` route (write path) lands in the next milestone.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::post,
    Extension, Json, Router,
};
use picloud_shared::{AppId, GroupId, Principal};
use serde::Deserialize;
use serde_json::json;

use crate::app_repo::AppRepository;
use crate::apply_service::{
    ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, NodeKind, PlanResult,
    TreeBundle, TreePlanResult,
};
use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository;

/// Build the apply/plan router. Mounted under `/api/v1/admin`.
pub fn apply_router(service: ApplyService) -> Router {
    Router::new()
        .route("/apps/{id}/plan", post(plan_handler))
        .route("/apps/{id}/apply", post(apply_handler))
        .route("/groups/{id}/plan", post(group_plan_handler))
        .route("/groups/{id}/apply", post(group_apply_handler))
        .route("/tree/plan", post(tree_plan_handler))
        .route("/tree/apply", post(tree_apply_handler))
        .with_state(service)
}

#[derive(Deserialize)]
pub struct ApplyRequest {
    pub bundle: Bundle,
    #[serde(default)]
    pub prune: bool,
    /// Optional bound-plan token from a prior `plan`. When present, apply
    /// refuses (409) if the app's live state has changed since.
    #[serde(default)]
    pub expected_token: Option<String>,
}

async fn apply_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
    let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
    require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
    let report = svc
        .apply(
            app_id,
            &req.bundle,
            req.prune,
            principal.user_id,
            req.expected_token.as_deref(),
        )
        .await?;
    Ok(Json(report))
}

async fn plan_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(bundle): Json<Bundle>,
) -> Result<Json<PlanResult>, ApplyError> {
    let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
    // NOTE: the returned `Plan` discloses live secret NAMES (not values). That
    // is safe today only because `AppRead` and `AppSecretsRead` are co-granted
    // at every tier (same `script:read` scope, both in the viewer role). If a
    // future authz split puts `AppSecretsRead` on its own tier, this handler
    // must additionally require it — otherwise it leaks names a principal
    // couldn't enumerate via the secrets API.
    require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
        .await
        .map_err(map_authz)?;
    let plan = svc.plan(app_id, &bundle).await?;
    Ok(Json(plan))
}

// ----------------------------------------------------------------------------
// Group node apply (Phase 5): a group declares only scripts + vars (+ secret
// names). The bundle reuses the app `Bundle` shape with empty routes/triggers;
// the service rejects routes/triggers on a group node.
// ----------------------------------------------------------------------------

async fn group_plan_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(bundle): Json<Bundle>,
) -> Result<Json<PlanResult>, ApplyError> {
    let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
    // Plan discloses the group's script + var + secret names; viewer-tier reads.
    require(
        svc.authz.as_ref(),
        &principal,
        Capability::GroupScriptsRead(group_id),
    )
    .await
    .map_err(map_authz)?;
    let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
    Ok(Json(plan))
}

async fn group_apply_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
    let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
    require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
    let report = svc
        .apply_owner(
            ApplyOwner::Group(group_id),
            &req.bundle,
            req.prune,
            principal.user_id,
            req.expected_token.as_deref(),
        )
        .await?;
    Ok(Json(report))
}

// ----------------------------------------------------------------------------
// Tree apply (Phase 5): a whole project subtree in one transaction. The actor
// must hold the relevant read/write caps for EVERY node touched.
// ----------------------------------------------------------------------------

#[derive(Deserialize)]
struct TreeApplyRequest {
    bundle: TreeBundle,
    #[serde(default)]
    prune: bool,
    #[serde(default)]
    expected_token: Option<String>,
}

async fn tree_plan_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Json(bundle): Json<TreeBundle>,
) -> Result<Json<TreePlanResult>, ApplyError> {
    authz_tree(&svc, &principal, &bundle, None).await?;
    Ok(Json(svc.plan_tree(&bundle).await?))
}

async fn tree_apply_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Json(req): Json<TreeApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
    authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
    let report = svc
        .apply_tree(
            &req.bundle,
            req.prune,
            principal.user_id,
            req.expected_token.as_deref(),
        )
        .await?;
    Ok(Json(report))
}

/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
/// only the per-node read cap.
async fn authz_tree(
    svc: &ApplyService,
    principal: &Principal,
    bundle: &TreeBundle,
    apply_prune: Option<bool>,
) -> Result<(), ApplyError> {
    for node in &bundle.nodes {
        match node.kind {
            NodeKind::App => {
                let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
                match apply_prune {
                    Some(prune) => {
                        require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
                            .await?;
                    }
                    None => {
                        require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
                            .await
                            .map_err(map_authz)?;
                    }
                }
            }
            NodeKind::Group => {
                let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
                match apply_prune {
                    Some(prune) => {
                        require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
                            .await?;
                    }
                    None => {
                        require(
                            svc.authz.as_ref(),
                            principal,
                            Capability::GroupScriptsRead(group_id),
                        )
                        .await
                        .map_err(map_authz)?;
                    }
                }
            }
        }
    }
    Ok(())
}

/// App-node write caps: per resource kind the bundle touches, widened to all
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
/// is always needed. Shared by the single-app and tree apply paths.
async fn require_app_node_writes(
    svc: &ApplyService,
    principal: &Principal,
    app_id: picloud_shared::AppId,
    bundle: &Bundle,
    prune: bool,
) -> Result<(), ApplyError> {
    require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
        .await
        .map_err(map_authz)?;
    if prune || !bundle.scripts.is_empty() {
        require(
            svc.authz.as_ref(),
            principal,
            Capability::AppWriteScript(app_id),
        )
        .await
        .map_err(map_authz)?;
    }
    if prune || !bundle.routes.is_empty() {
        require(
            svc.authz.as_ref(),
            principal,
            Capability::AppWriteRoute(app_id),
        )
        .await
        .map_err(map_authz)?;
    }
    if prune || !bundle.triggers.is_empty() {
        require(
            svc.authz.as_ref(),
            principal,
            Capability::AppManageTriggers(app_id),
        )
        .await
        .map_err(map_authz)?;
    }
    if prune || !bundle.vars.is_empty() {
        require(
            svc.authz.as_ref(),
            principal,
            Capability::AppVarsWrite(app_id),
        )
        .await
        .map_err(map_authz)?;
    }
    // Email triggers resolve + decrypt a stored secret by name server-side
    // (`AppSecretsRead`), so require it so apply can't bind a secret the
    // principal couldn't otherwise read.
    if bundle.triggers.iter().any(BundleTrigger::is_email) {
        require(
            svc.authz.as_ref(),
            principal,
            Capability::AppSecretsRead(app_id),
        )
        .await
        .map_err(map_authz)?;
    }
    Ok(())
}

/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars →
/// `GroupVarsWrite`, both on prune.
async fn require_group_node_writes(
    svc: &ApplyService,
    principal: &Principal,
    group_id: GroupId,
    bundle: &Bundle,
    prune: bool,
) -> Result<(), ApplyError> {
    if prune || !bundle.scripts.is_empty() {
        require(
            svc.authz.as_ref(),
            principal,
            Capability::GroupScriptsWrite(group_id),
        )
        .await
        .map_err(map_authz)?;
    }
    if prune || !bundle.vars.is_empty() {
        require(
            svc.authz.as_ref(),
            principal,
            Capability::GroupVarsWrite(group_id),
        )
        .await
        .map_err(map_authz)?;
    }
    Ok(())
}

/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
    groups: &dyn GroupRepository,
    ident: &str,
) -> Result<GroupId, ApplyError> {
    let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
        groups
            .get_by_id(uuid.into())
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?
    } else {
        groups
            .get_by_slug(ident)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?
    };
    found
        .map(|g| g.id)
        .ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
}

/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
/// Mirrors the `triggers_api` helper of the same shape.
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
    crate::app_repo::resolve_app(apps, ident)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?
        .map(|l| l.app.id)
        .ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
}

fn map_authz(denied: AuthzDenied) -> ApplyError {
    match denied {
        AuthzDenied::Denied => ApplyError::Forbidden,
        AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
    }
}

impl IntoResponse for ApplyError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
            Self::Invalid(_) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": self.to_string() }),
            ),
            Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
            Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "apply authz repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Backend(e) => {
                tracing::error!(error = %e, "apply backend error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}
