//! 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::{get, 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, CollectionInfo,
    ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, SuppressionInfo, TreeBundle,
    TreePlanResult, TriggerTemplateInfo,
};
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))
        .route(
            "/apps/{id}/extension-points",
            get(app_extension_points_handler),
        )
        .route(
            "/groups/{id}/extension-points",
            get(group_extension_points_handler),
        )
        .route("/groups/{id}/collections", get(group_collections_handler))
        .route("/groups/{id}/triggers", get(group_triggers_handler))
        .route("/groups/{id}/routes", get(group_routes_handler))
        .route("/apps/{id}/suppressions", get(app_suppressions_handler))
        .route("/groups/{id}/suppressions", get(group_suppressions_handler))
        .with_state(service)
}

/// Read-only §11 tail suppression report for an app: the inherited templates it
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
/// `pic suppress ls --app`.
async fn app_suppressions_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<SuppressionInfo>>, ApplyError> {
    let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
    require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
        .await
        .map_err(map_authz)?;
    let report = svc.suppression_report(ApplyOwner::App(app_id)).await?;
    Ok(Json(report))
}

/// Read-only §11 tail M1 suppression report for a group: the inherited templates
/// it declines for its whole subtree (`target_kind`, `reference`). Viewer-tier
/// `GroupScriptsRead`. Backs `pic suppress ls --group`.
async fn group_suppressions_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<SuppressionInfo>>, ApplyError> {
    let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
    require(
        svc.authz.as_ref(),
        &principal,
        Capability::GroupScriptsRead(group_id),
    )
    .await
    .map_err(map_authz)?;
    let report = svc.suppression_report(ApplyOwner::Group(group_id)).await?;
    Ok(Json(report))
}

/// Read-only §11 tail route-template report for a group: its own declared route
/// templates (method, host, path, handler script, dispatch, enabled). Viewer-
/// tier read. Backs `pic routes ls --group`.
async fn group_routes_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<RouteTemplateInfo>>, ApplyError> {
    let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
    require(
        svc.authz.as_ref(),
        &principal,
        Capability::GroupScriptsRead(group_id),
    )
    .await
    .map_err(map_authz)?;
    let report = svc.route_report(ApplyOwner::Group(group_id)).await?;
    Ok(Json(report))
}

/// Read-only §11 tail trigger-template report for a group: its own declared
/// event trigger templates (kind, target, handler script, enabled). Viewer-tier
/// read. Backs `pic triggers ls --group`.
async fn group_triggers_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<TriggerTemplateInfo>>, ApplyError> {
    let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
    require(
        svc.authz.as_ref(),
        &principal,
        Capability::GroupScriptsRead(group_id),
    )
    .await
    .map_err(map_authz)?;
    let report = svc.trigger_report(ApplyOwner::Group(group_id)).await?;
    Ok(Json(report))
}

/// Read-only §11.6 shared-collection report for a group: its own declared
/// shared KV collection names. Viewer-tier read.
async fn group_collections_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<CollectionInfo>>, ApplyError> {
    let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
    require(
        svc.authz.as_ref(),
        &principal,
        Capability::GroupScriptsRead(group_id),
    )
    .await
    .map_err(map_authz)?;
    let report = svc.collection_report(ApplyOwner::Group(group_id)).await?;
    Ok(Json(report))
}

/// Read-only §5.5 extension-point report for an app: every EP visible on its
/// chain, each with `declared_here` + resolved `provider`. Viewer-tier read.
async fn app_extension_points_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
    let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
    require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
        .await
        .map_err(map_authz)?;
    let report = svc.extension_point_report(ApplyOwner::App(app_id)).await?;
    Ok(Json(report))
}

/// Read-only §5.5 extension-point report for a group: its own declared names.
async fn group_extension_points_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
    let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
    require(
        svc.authz.as_ref(),
        &principal,
        Capability::GroupScriptsRead(group_id),
    )
    .await
    .map_err(map_authz)?;
    let report = svc
        .extension_point_report(ApplyOwner::Group(group_id))
        .await?;
    Ok(Json(report))
}

#[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>,
    /// Selected environment (the overlay applied). Drives per-env approval
    /// gating (§4.2, M5).
    #[serde(default)]
    env: Option<String>,
    /// Environments the actor explicitly approved this apply for (§4.2, M5). A
    /// confirm-required env (per the bundle's `[project]` policy) is refused
    /// unless it appears here; `--yes` alone does NOT add it.
    #[serde(default)]
    approved_envs: Vec<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?;
    enforce_env_approval(
        &svc,
        &principal,
        &req.bundle,
        req.env.as_deref(),
        &req.approved_envs,
    )
    .await?;
    let report = svc
        .apply_tree(
            &req.bundle,
            req.prune,
            principal.user_id,
            req.expected_token.as_deref(),
        )
        .await?;
    Ok(Json(report))
}

/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
/// enforcement is convenience, this is authoritative), it must be explicitly
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
/// authority on every declared node — the "override a gate" capability (§4.2), a
/// deliberate step up from the editor-level write caps an ordinary apply needs —
/// and is audited.
async fn enforce_env_approval(
    svc: &ApplyService,
    principal: &Principal,
    bundle: &TreeBundle,
    env: Option<&str>,
    approved_envs: &[String],
) -> Result<(), ApplyError> {
    let (Some(policy), Some(env)) = (&bundle.project, env) else {
        return Ok(());
    };
    if !policy.confirm_required(env) {
        return Ok(());
    }
    if !approved_envs.iter().any(|e| e == env) {
        return Err(ApplyError::ApprovalRequired(env.to_string()));
    }
    // Approved: require admin authority on every declared node.
    for node in &bundle.nodes {
        match node.kind {
            NodeKind::App => {
                let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
                require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
                    .await
                    .map_err(map_authz)?;
            }
            NodeKind::Group => {
                if let Some(g) = svc
                    .groups
                    .get_by_slug(&node.slug)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                {
                    require(svc.authz.as_ref(), principal, Capability::GroupAdmin(g.id))
                        .await
                        .map_err(map_authz)?;
                }
            }
        }
    }
    tracing::info!(
        user_id = ?principal.user_id,
        env = %env,
        nodes = bundle.nodes.len(),
        "apply: approved gated-environment apply (audit)"
    );
    Ok(())
}

/// 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 | Self::ApprovalRequired(_) => {
                (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()
    }
}
