//! 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, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
    StructureMode, 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))
}

/// A `plan` request (§7 M3): the desired bundle plus the optional declaring
/// `[project]`, so the plan can preview the ownership outcome + attach ceiling.
#[derive(Deserialize)]
pub struct PlanRequest {
    // Flattened so the bundle fields sit at the JSON top level — restoring the
    // pre-§7 plan wire, which took a BARE `Json<Bundle>`. An older `pic` that
    // POSTs a bare bundle still deserializes (`project` defaults to `None`), and
    // a newer client sends the same bundle fields plus a top-level `project`
    // key. (Apply is deliberately NOT flattened — its pre-§7 wire was already
    // `{bundle, prune, …}`, so flattening it would instead break old apply
    // clients; the two endpoints are asymmetric because their histories are.)
    #[serde(flatten)]
    pub bundle: Bundle,
    #[serde(default)]
    pub project: Option<ProjectDecl>,
}

#[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>,
    /// §7 ownership: the declaring repo's `[project]` (absent = no claim). Also
    /// carries the §3 M3 env approval policy (`project.environments`).
    #[serde(default)]
    pub project: Option<ProjectDecl>,
    /// §7 ownership: reassign a node owned by another project (group-admin).
    #[serde(default)]
    pub takeover: bool,
    /// §3 M3: the environment this apply targets (the overlay merged). Drives the
    /// per-env approval gate when the project marks it `confirm = true`.
    #[serde(default)]
    pub env: Option<String>,
    /// §3 M3: environments the actor explicitly approved (`--approve <env>`). A
    /// confirm-required env is refused unless it appears here; `--yes` alone does
    /// NOT add it.
    #[serde(default)]
    pub approved_envs: Vec<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?;
    // §3 M3: server-authoritative per-env approval gate. The governing policy is
    // loaded from the project the SERVER resolves as owning this node (its
    // nearest-claimed ancestor), unioned with the declared policy — so a request
    // that omits or spoofs `[project]` can't bypass a gate the owning project
    // established. If gated and approved, the override additionally requires
    // ADMIN on the node (a step up from the editor write caps above) + is audited.
    let policy = svc
        .governing_env_policy(ApplyOwner::App(app_id), req.project.as_ref())
        .await?;
    if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
        require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id))
            .await
            .map_err(map_authz)?;
        audit_gated_apply(&principal, req.env.as_deref(), 1);
    }
    let claim = OwnershipClaim {
        project: req.project,
        takeover: req.takeover,
        principal: &principal,
    };
    let report = svc
        .apply(
            app_id,
            &req.bundle,
            req.prune,
            &claim,
            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(req): Json<PlanRequest>,
) -> 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, &req.bundle, req.project.as_ref()).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(req): Json<PlanRequest>,
) -> 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),
            &req.bundle,
            req.project.as_ref(),
        )
        .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?;
    svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune)
        .await?;
    // §3 M3: per-env approval gate (governing ∪ declared, server-resolved owner)
    // — an approved gated apply requires GroupAdmin.
    let policy = svc
        .governing_env_policy(ApplyOwner::Group(group_id), req.project.as_ref())
        .await?;
    if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
        require(
            svc.authz.as_ref(),
            &principal,
            Capability::GroupAdmin(group_id),
        )
        .await
        .map_err(map_authz)?;
        audit_gated_apply(&principal, req.env.as_deref(), 1);
    }
    let claim = OwnershipClaim {
        project: req.project,
        takeover: req.takeover,
        principal: &principal,
    };
    let report = svc
        .apply_owner(
            ApplyOwner::Group(group_id),
            &req.bundle,
            req.prune,
            &claim,
            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>,
    /// §7 ownership: one `[project]` for the whole tree (the root manifest's).
    /// Also carries the §3 M3 env approval policy (`project.environments`).
    #[serde(default)]
    project: Option<ProjectDecl>,
    #[serde(default)]
    takeover: bool,
    /// §6 M2: how to resolve a group whose server parent diverges from the
    /// manifest (default `Refuse`; a pre-M2 CLI omits it).
    #[serde(default)]
    structure_mode: StructureMode,
    /// §3 M3: the environment this apply targets (drives the per-env approval gate).
    #[serde(default)]
    env: Option<String>,
    /// §3 M3: environments the actor explicitly approved (`--approve <env>`).
    #[serde(default)]
    approved_envs: Vec<String>,
}

#[derive(Deserialize)]
struct TreePlanRequest {
    // Flattened for the same bare-bundle wire compatibility as `PlanRequest`.
    #[serde(flatten)]
    bundle: TreeBundle,
    #[serde(default)]
    project: Option<ProjectDecl>,
}

async fn tree_plan_handler(
    State(svc): State<ApplyService>,
    Extension(principal): Extension<Principal>,
    Json(req): Json<TreePlanRequest>,
) -> Result<Json<TreePlanResult>, ApplyError> {
    authz_tree(&svc, &principal, &req.bundle, None).await?;
    Ok(Json(
        svc.plan_tree(&req.bundle, req.project.as_ref()).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?;
    // §3 M3: server-authoritative per-env approval gate (governing ∪ declared).
    // The governing side is the union of every declared node's owning-project
    // policy, resolved server-side — omitting `[project]` can't dodge it. On an
    // approved gated apply, require ADMIN on EVERY declared node (a step up from
    // the editor write caps authz_tree checks) + audit.
    let policy = svc
        .governing_env_policy_tree(&req.bundle, req.project.as_ref())
        .await?;
    if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
        require_admin_on_every_node(&svc, &principal, &req.bundle).await?;
        audit_gated_apply(&principal, req.env.as_deref(), req.bundle.nodes.len());
    }
    let claim = OwnershipClaim {
        project: req.project,
        takeover: req.takeover,
        principal: &principal,
    };
    let report = svc
        .apply_tree(
            &req.bundle,
            req.prune,
            &claim,
            req.expected_token.as_deref(),
            req.structure_mode,
        )
        .await?;
    Ok(Json(report))
}

/// §3 M3 (hermetic gate): the per-env approval gate, evaluated against the
/// EFFECTIVE policy (`governing ∪ declared`, built by
/// `ApplyService::governing_env_policy`) — the governing side is loaded from the
/// project the SERVER resolves as owning the node, so a request that omits or
/// weakens `[project]` can't bypass a gate the owning project established.
/// Returns `Ok(true)` when the target env is confirm-required
/// AND approved — the caller must then require admin + audit. `Ok(false)` when
/// not gated (no env, or `confirm = false` everywhere). `Err(ApprovalRequired)`
/// when gated and NOT in `approved_envs` (a blanket `--yes` never lands here).
fn env_gate_check(
    policy: &std::collections::BTreeMap<String, bool>,
    env: Option<&str>,
    approved_envs: &[String],
) -> Result<bool, ApplyError> {
    let Some(env) = env else {
        return Ok(false);
    };
    if !policy.get(env).copied().unwrap_or(false) {
        return Ok(false);
    }
    if approved_envs.iter().any(|e| e == env) {
        Ok(true)
    } else {
        Err(ApplyError::ApprovalRequired(env.to_string()))
    }
}

/// §3 M3: require `AppAdmin`/`GroupAdmin` on every declared node of a tree apply
/// (the "override a gate" authority, above editor write caps). Mirrors
/// `authz_tree`'s resolution: an app resolves UUID-or-slug (fail-closed); a group
/// node whose slug names no existing group is to-CREATE (its create is separately
/// admin-gated in the service), so it is skipped here rather than 404'd.
async fn require_admin_on_every_node(
    svc: &ApplyService,
    principal: &Principal,
    bundle: &TreeBundle,
) -> 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?;
                require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
                    .await
                    .map_err(map_authz)?;
            }
            NodeKind::Group => {
                if let Some(group) = svc
                    .groups
                    .get_by_slug(&node.slug)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                {
                    require(
                        svc.authz.as_ref(),
                        principal,
                        Capability::GroupAdmin(group.id),
                    )
                    .await
                    .map_err(map_authz)?;
                }
            }
        }
    }
    Ok(())
}

/// §3 M3: audit an approved gated-environment apply (actor + env + node count).
fn audit_gated_apply(principal: &Principal, env: Option<&str>, nodes: usize) {
    tracing::info!(
        user_id = ?principal.user_id,
        env = ?env,
        nodes,
        "apply: approved gated-environment apply (audit)"
    );
}

/// 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 => {
                // §6: a group node that doesn't exist yet is to-CREATE — its
                // authorization (InstanceCreateGroup / GroupAdmin(parent)) is
                // enforced in the service's create path, so skip the
                // existing-group capability check here rather than 404.
                let Some(group) = svc
                    .groups
                    .get_by_slug(&node.slug)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                else {
                    continue;
                };
                let group_id = group.id;
                match apply_prune {
                    Some(prune) => {
                        svc.require_group_node_writes(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(())
}

/// 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(_) | Self::OutsideAttachPoint(_) | Self::StructuralDivergence(_) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": self.to_string() }),
            ),
            // 409 CONFLICT, all actionable: a stale bound plan (StateMoved), a
            // §3 M3 gated env needing `--approve` (ApprovalRequired), or a §7
            // ownership conflict (declare `[project]`, `--takeover`).
            Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => {
                (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()
    }
}

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

    // §7 wire compat: the plan endpoints `#[serde(flatten)]` the bundle, so a
    // pre-§7 client that POSTs a BARE bundle (no `{bundle: ...}` wrapper, no
    // `project`) must still deserialize — `project` defaults to `None`.
    #[test]
    fn plan_request_accepts_a_bare_bundle() {
        let bare = serde_json::json!({
            "scripts": [],
            "secrets": ["TOKEN"],
        });
        let req: PlanRequest = serde_json::from_value(bare).expect("bare bundle deserializes");
        assert!(req.project.is_none());
        assert_eq!(req.bundle.secrets, vec!["TOKEN".to_string()]);
    }

    // The newer wire shape — the same bundle fields at the top level plus a
    // `project` key (what the current CLI sends) — also deserializes.
    #[test]
    fn plan_request_accepts_a_flattened_project() {
        let with_proj = serde_json::json!({
            "scripts": [],
            "project": { "slug": "acme" },
        });
        let req: PlanRequest = serde_json::from_value(with_proj).expect("flattened project");
        assert_eq!(req.project.expect("project").slug, "acme");
    }

    #[test]
    fn tree_plan_request_accepts_a_bare_bundle() {
        let bare = serde_json::json!({ "nodes": [] });
        let req: TreePlanRequest = serde_json::from_value(bare).expect("bare tree bundle");
        assert!(req.project.is_none());
    }
}
