//! Admin endpoints for routes. Mounted under `/api/v1/admin` alongside
//! the script CRUD endpoints; the picloud binary wires the
//! `RouteTable` shared with the orchestrator dispatcher in here so
//! writes invalidate the in-memory snapshot.

use std::sync::Arc;

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{delete, get, post},
    Extension, Json, Router,
};
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId, ScriptOwner};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::app_domain_repo::AppDomainRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{EffectiveRoute, NewRoute, RouteRepository};

pub struct RouteAdminState<RR, SR> {
    pub routes: Arc<RR>,
    /// Used to resolve `script_id → app_id` when creating routes (the
    /// route inherits the script's app) and to scope conflict checks.
    pub scripts: Arc<SR>,
    /// Used to validate the route's host against the parent app's
    /// declared domain claims.
    pub domains: Arc<dyn AppDomainRepository>,
    pub table: Arc<RouteTable>,
    /// Capability gate — Phase 3.5.
    pub authz: Arc<dyn AuthzRepo>,
}

impl<RR, SR> Clone for RouteAdminState<RR, SR> {
    fn clone(&self) -> Self {
        Self {
            routes: self.routes.clone(),
            scripts: self.scripts.clone(),
            domains: self.domains.clone(),
            table: self.table.clone(),
            authz: self.authz.clone(),
        }
    }
}

pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
where
    RR: RouteRepository + 'static,
    SR: ScriptRepository + 'static,
{
    Router::new()
        .route(
            "/scripts/{id}/routes",
            get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
        )
        .route("/routes/{route_id}", delete(delete_route::<RR, SR>))
        .route("/routes:check", post(check_route::<RR, SR>))
        .route("/routes:match", post(match_route::<RR, SR>))
        .with_state(state)
}

// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
pub struct CreateRouteRequest {
    pub host_kind: HostKind,
    #[serde(default)]
    pub host: String,
    #[serde(default)]
    pub host_param_name: Option<String>,
    pub path_kind: PathKind,
    pub path: String,
    pub method: Option<String>,
    /// Per-route dispatch mode (v1.1.1). Defaults to `Sync` when
    /// omitted so older clients aren't broken. `Async` routes return
    /// `202 Accepted` immediately and run the script in the
    /// background via the dispatcher.
    #[serde(default)]
    pub dispatch_mode: picloud_shared::DispatchMode,
}

#[derive(Debug, Deserialize)]
pub struct CheckRouteRequest {
    /// Required: which app's route table this hypothetical route would
    /// join. Conflict checks are strictly intra-app (cross-app route
    /// errors would leak tenant info — see blueprint §11.5).
    pub app_id: AppId,
    pub host_kind: HostKind,
    #[serde(default)]
    pub host: String,
    pub path_kind: PathKind,
    pub path: String,
    pub method: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct CheckRouteResponse {
    pub ok: bool,
    pub conflicting_route: Option<Route>,
    pub conflict_reason: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct MatchRouteRequest {
    /// Which app's route table to dispatch against. The dashboard's
    /// route-preview tester always knows the current app context.
    pub app_id: AppId,
    pub url: String,
    #[serde(default = "default_method")]
    pub method: String,
}

fn default_method() -> String {
    "GET".into()
}

#[derive(Debug, Serialize)]
pub struct MatchRouteResponse {
    pub matched: Option<MatchedRoute>,
}

#[derive(Debug, Serialize)]
pub struct MatchedRoute {
    pub route_id: Uuid,
    pub script_id: ScriptId,
    pub params: std::collections::BTreeMap<String, String>,
    pub rest: Option<String>,
    pub host_param: Option<(String, String)>,
}

// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------

async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
    State(state): State<RouteAdminState<RR, SR>>,
    Extension(principal): Extension<Principal>,
    Path(script_id): Path<ScriptId>,
) -> Result<Json<Vec<Route>>, RouteApiError> {
    let script = state
        .scripts
        .get(script_id)
        .await?
        .ok_or(RouteApiError::ScriptNotFound(script_id))?;
    // Phase 4: routes bind to app-owned scripts; a group-owned script
    // (`app_id: None`) is not addressable here (binding lands in C3), so it
    // reads as a missing script.
    let app_id = script
        .app_id
        .ok_or(RouteApiError::ScriptNotFound(script_id))?;
    require(
        state.authz.as_ref(),
        &principal,
        Capability::AppRead(app_id),
    )
    .await?;
    Ok(Json(state.routes.list_for_script(script_id).await?))
}

async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
    State(state): State<RouteAdminState<RR, SR>>,
    Extension(principal): Extension<Principal>,
    Path(script_id): Path<ScriptId>,
    Json(input): Json<CreateRouteRequest>,
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
    let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
    pattern::parse_host(
        input.host_kind,
        &input.host,
        input.host_param_name.as_deref(),
    )?;

    // Look up the script's owning app — every route inherits it.
    let script = state
        .scripts
        .get(script_id)
        .await?
        .ok_or(RouteApiError::ScriptNotFound(script_id))?;
    // Phase 4: only app-owned scripts are route-bindable for now (group
    // binding is C3); a group script reads as missing here.
    let app_id = script
        .app_id
        .ok_or(RouteApiError::ScriptNotFound(script_id))?;
    require(
        state.authz.as_ref(),
        &principal,
        Capability::AppWriteRoute(app_id),
    )
    .await?;

    // v1.1.3: module scripts have no executable entry point — they're
    // libraries imported by other scripts. Reject route bindings here
    // before we touch the routes table.
    if script.kind == picloud_shared::ScriptKind::Module {
        return Err(RouteApiError::BadRequest(format!(
            "script {script_id} has kind=module; modules are imported, \
             not bound to routes — switch the script to kind=endpoint \
             or attach this route to a different script"
        )));
    }

    // Validate the route's host is consistent with one of the app's
    // domain claims. `HostKind::Any` is always permitted (catches every
    // host the app already owns). Specific hosts must match a claim.
    validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
        .await?;

    // Within-app conflict check (cross-app is impossible by construction).
    let existing = state.routes.list_for_app(app_id).await?;
    if let Some((conflicting, reason)) = first_conflict(
        &existing,
        input.host_kind,
        &input.host,
        input.path_kind,
        &normalized_path,
        input.method.as_deref(),
    )? {
        return Err(RouteApiError::Conflict {
            conflicting_route: Box::new(conflicting),
            reason,
        });
    }

    let created = state
        .routes
        .create(NewRoute {
            owner: ScriptOwner::App(app_id),
            script_id,
            host_kind: input.host_kind,
            host: input.host,
            host_param_name: input.host_param_name,
            path_kind: input.path_kind,
            path: normalized_path,
            method: input.method,
            dispatch_mode: input.dispatch_mode,
            // Routes are created active; toggling is a dedicated path.
            enabled: true,
            // Sealing is a group-template property (§11 tail); an interactively
            // created app route is never sealed.
            sealed: false,
        })
        .await?;
    refresh_table(&state).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
    State(state): State<RouteAdminState<RR, SR>>,
    Extension(principal): Extension<Principal>,
    Path(route_id): Path<Uuid>,
) -> Result<StatusCode, RouteApiError> {
    // Resolve the route's app before we delete, so the capability
    // binds to the actual route's app_id (not a path param).
    let route = state
        .routes
        .get(route_id)
        .await?
        .ok_or(RouteApiError::RouteNotFound(route_id))?;
    // §11 tail: the interactive route API is app-scoped. A group-owned route
    // TEMPLATE (`app_id` None) is managed declaratively via apply, not
    // addressable here — treat it as not found.
    let route_app_id = route.app_id.ok_or(RouteApiError::RouteNotFound(route_id))?;
    require(
        state.authz.as_ref(),
        &principal,
        Capability::AppWriteRoute(route_app_id),
    )
    .await?;
    state.routes.delete(route_id).await?;
    refresh_table(&state).await?;
    Ok(StatusCode::NO_CONTENT)
}

async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
    State(state): State<RouteAdminState<RR, SR>>,
    Extension(principal): Extension<Principal>,
    Json(input): Json<CheckRouteRequest>,
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
    // routes:check is read-only — peeking at a hypothetical conflict
    // is bounded by AppRead on the target app (otherwise members
    // could probe other apps).
    require(
        state.authz.as_ref(),
        &principal,
        Capability::AppRead(input.app_id),
    )
    .await?;
    let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
    pattern::parse_host(input.host_kind, &input.host, None)?;

    let existing = state.routes.list_for_app(input.app_id).await?;
    let conflict = first_conflict(
        &existing,
        input.host_kind,
        &input.host,
        input.path_kind,
        &normalized_path,
        input.method.as_deref(),
    )?;
    Ok(Json(match conflict {
        None => CheckRouteResponse {
            ok: true,
            conflicting_route: None,
            conflict_reason: None,
        },
        Some((route, reason)) => CheckRouteResponse {
            ok: false,
            conflicting_route: Some(route),
            conflict_reason: Some(reason),
        },
    }))
}

async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
    State(state): State<RouteAdminState<RR, SR>>,
    Extension(principal): Extension<Principal>,
    Json(input): Json<MatchRouteRequest>,
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
    require(
        state.authz.as_ref(),
        &principal,
        Capability::AppRead(input.app_id),
    )
    .await?;
    let parsed = url::Url::parse(&input.url)
        .map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
    let host = parsed.host_str().unwrap_or("").to_string();
    let path = parsed.path().to_string();

    let result = state
        .table
        .match_request_for_app(input.app_id, &host, &input.method, &path);
    Ok(Json(MatchRouteResponse {
        matched: result.map(|r| MatchedRoute {
            route_id: r.matched.route_id,
            script_id: r.matched.script_id,
            params: r.params,
            rest: r.rest,
            host_param: r.host_param,
        }),
    }))
}

// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------

/// Validate the raw user-typed path string and return it verbatim if
/// it parses cleanly. Prefix normalization (`/echo/*` → `/echo/`)
/// happens only in memory at compile time; persisted strings stay in
/// the form the user submitted so re-parses are idempotent.
fn parse_and_normalize_path(kind: PathKind, raw: &str) -> Result<String, pattern::ParseError> {
    pattern::parse_path(kind, raw)?;
    Ok(raw.to_string())
}

#[allow(clippy::type_complexity)]
fn first_conflict(
    existing: &[Route],
    host_kind: HostKind,
    host: &str,
    path_kind: PathKind,
    path: &str,
    method: Option<&str>,
) -> Result<Option<(Route, String)>, RouteApiError> {
    let new_host = pattern::parse_host(host_kind, host, None)?;
    let new_path = pattern::parse_path(path_kind, path)?;

    for r in existing {
        let r_host = pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?;
        if !conflict::hosts_overlap(&new_host, &r_host) {
            continue;
        }
        if !conflict::methods_overlap(method, r.method.as_deref()) {
            continue;
        }
        let r_path = pattern::parse_path(r.path_kind, &r.path)?;
        if let Some(reason) = conflict::conflicts(&new_path, &r_path) {
            return Ok(Some((r.clone(), format!("{reason:?}"))));
        }
    }
    Ok(None)
}

async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
    state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> {
    rebuild_route_table(state.routes.as_ref(), &state.table).await?;
    Ok(())
}

/// Rebuild the entire in-memory [`RouteTable`] from the database, expanding
/// §11 tail group route TEMPLATES into every descendant app's slice.
///
/// This is the single chokepoint for route-table refresh — called from route
/// CRUD, the declarative apply reconcile, startup, and (full-live invalidation)
/// every tree mutation that changes inheritance (app create/reparent/delete,
/// group reparent). It reads the live expansion (`list_effective`) so a group
/// template lands in the slice of each app whose ancestor chain contains the
/// owning group, and nowhere else — the chain walk is the isolation boundary.
pub async fn rebuild_route_table(
    routes: &dyn RouteRepository,
    table: &RouteTable,
) -> Result<(), ScriptRepositoryError> {
    let effective = routes.list_effective().await?;
    // §11 tail: route suppressions keyed `(app_id, path)` → the SMALLEST
    // suppressor depth seen for that key. An inherited route is dropped only
    // when its owner is above the suppressor (`route_depth > suppressor_depth`),
    // so a group's decline of an ancestor template never clobbers a nearer
    // descendant's own route at the same path.
    let mut suppressed: std::collections::HashMap<(AppId, String), i32> =
        std::collections::HashMap::new();
    for (app, path, depth) in routes.list_route_suppressions().await? {
        suppressed
            .entry((app, path))
            .and_modify(|d| *d = (*d).min(depth))
            .or_insert(depth);
    }
    let compiled = compile_effective_routes(&effective, &suppressed);
    table.replace_all(compiled);
    Ok(())
}

/// Compile the §11 tail effective expansion into per-app match slices, applying
/// **nearest-owner-wins shadowing**: for a given app, if the same binding tuple
/// (method + host + path) is owned at more than one chain level — e.g. the app
/// declares its own `/x` and an ancestor group also templates `/x` — the
/// nearest owner (lowest `depth`) wins and the farther one is dropped. Unlike
/// triggers (which fan out to every match), a route resolves to a single
/// winner, so identical bindings MUST dedupe; **non-identical** bindings (the
/// app's `/users/:id` + the group's `/users/admin`) coexist and the matcher's
/// existing precedence picks per request.
///
/// This is the single production entry point: app-only installs feed it routes
/// at `depth 0` (one per app, no templates), so it subsumes the former app-only
/// compile path. Disabled routes are dropped (§4.3) and un-compilable rows
/// skipped-with-warning (see [`compile_one`]).
///
/// **Disabled + inherited (§4.3 semantic):** the `enabled` filter runs *before*
/// the shadow check, so a **disabled** own-route does NOT claim its binding — an
/// enabled ancestor-group template at the same binding then falls through and
/// serves (disabled = "indistinguishable from absent"). To actually 404 an
/// inherited route, a descendant SUPPRESSES its path (§11 tail, the
/// `suppressed_paths` set) — the deliberate per-app opt-out.
///
/// `suppressed_paths` maps `(app_id, path)` → the smallest suppressor depth for
/// that key. An INHERITED route (`depth > 0`) is skipped only when its owner is
/// strictly ABOVE the suppressor (`route.depth > suppressor_depth`) — so an
/// app's own suppression (depth 0) declines anything inherited, a group's
/// suppression declines only what that group itself inherits (owned above it),
/// and a nearer descendant's own re-declaration at the same path SURVIVES an
/// ancestor's suppression. The `route.depth > suppressor_depth` test also
/// subsumes the "inherited only" gate (a suppressor at depth `d` can only touch
/// rows at depth `> d ≥ 0`, never its own depth-0 route).
///
/// Requires `rows` ordered by `(effective_app_id, depth ASC)` — which
/// [`RouteRepository::list_effective`] guarantees — so first-seen is nearest.
#[must_use]
#[allow(clippy::implicit_hasher)] // every caller uses the default hasher
pub fn compile_effective_routes(
    rows: &[EffectiveRoute],
    suppressed_paths: &std::collections::HashMap<(AppId, String), i32>,
) -> Vec<CompiledRoute> {
    let mut seen: std::collections::HashSet<(AppId, String)> = std::collections::HashSet::new();
    let mut out = Vec::new();
    for er in rows {
        // A disabled route (§4.3) is dropped from the match table entirely, so a
        // request to it 404s indistinguishably from an absent route.
        if !er.route.enabled {
            continue;
        }
        // §11 tail: an inherited route whose path this app suppresses is
        // dropped — but ONLY if the route's owner is above the suppressor
        // (`route.depth > suppressor_depth`), so a descendant group's own
        // re-declaration at the same path isn't over-declined by an ancestor's
        // suppression. A `sealed` template is non-suppressible.
        if !er.route.sealed
            && suppressed_paths
                .get(&(er.effective_app_id, er.route.path.clone()))
                .is_some_and(|&suppressor_depth| er.depth > suppressor_depth)
        {
            continue;
        }
        // A nearer owner already claimed this binding for this app → shadow.
        if !seen.insert((er.effective_app_id, binding_key(&er.route))) {
            continue;
        }
        if let Some(compiled) = compile_one(&er.route, er.effective_app_id) {
            out.push(compiled);
        }
    }
    out
}

/// The shadow-identity of a route within one app's slice: the binding tuple a
/// request resolves against (method + host + path), matching the per-owner DB
/// unique index. Two routes with the same key are "the same endpoint" and only
/// the nearest owner's survives.
fn binding_key(r: &Route) -> String {
    let method = r
        .method
        .as_deref()
        .map_or("ANY".to_string(), str::to_uppercase);
    let host_kind = match r.host_kind {
        HostKind::Any => "any",
        HostKind::Strict => "strict",
        HostKind::Wildcard => "wildcard",
    };
    let path_kind = match r.path_kind {
        PathKind::Exact => "exact",
        PathKind::Prefix => "prefix",
        PathKind::Param => "param",
    };
    format!("{method} {host_kind} {} {path_kind} {}", r.host, r.path)
}

/// Parse one stored route into a `CompiledRoute` bound to `app_id` (the
/// effective app — its own for app routes, the firing descendant for a group
/// template).
///
/// **Lenient by design (H1).** A row that fails to parse is *skipped with a
/// warning*, not propagated as an error. The motivating case: a path that was
/// valid when created but became reserved under a later, stricter validation
/// (the case-insensitive reserved-prefix check) — but this also covers any
/// other parse failure. A single un-compilable row must never take down the
/// data plane: this runs at startup (where a hard error aborts boot) and on
/// every table rebuild after an edit (where it would fail an unrelated CRUD
/// op). A skipped route simply doesn't match; the warning tells the operator to
/// delete or fix it (and migration 0044 sweeps the reserved-path offenders on
/// upgrade).
fn compile_one(r: &Route, app_id: AppId) -> Option<CompiledRoute> {
    match compile_route(r, app_id) {
        Ok(compiled) => Some(compiled),
        Err(e) => {
            tracing::warn!(
                route_id = %r.id,
                app_id = %app_id,
                path = %r.path,
                error = %e,
                "skipping un-compilable stored route — it will not match; \
                 delete or fix it"
            );
            None
        }
    }
}

fn compile_route(r: &Route, app_id: AppId) -> Result<CompiledRoute, pattern::ParseError> {
    Ok(CompiledRoute {
        route_id: r.id,
        app_id,
        script_id: r.script_id,
        host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
        path: pattern::parse_path(r.path_kind, &r.path)?,
        method: r.method.clone(),
        dispatch_mode: r.dispatch_mode,
    })
}

/// Validate that a new route's (host_kind, host) is consistent with at
/// least one of the parent app's domain claims. `HostKind::Any` is
/// always permitted — it catches every host the app already owns.
pub(crate) async fn validate_route_host_against_app(
    domains: &dyn AppDomainRepository,
    app_id: AppId,
    host_kind: HostKind,
    host: &str,
) -> Result<(), RouteApiError> {
    if matches!(host_kind, HostKind::Any) {
        return Ok(());
    }
    let claims = domains.list_for_app(app_id).await?;
    if claims.is_empty() {
        return Err(RouteApiError::HostNotClaimed {
            host: host.to_string(),
            available_claims: vec![],
        });
    }

    let host_lower = host.to_ascii_lowercase();
    for claim in &claims {
        let claim_lower = claim.pattern.to_ascii_lowercase();
        match (host_kind, claim.shape) {
            // Strict route under exact claim: must match exactly.
            (HostKind::Strict, picloud_shared::DomainShape::Exact) => {
                if host_lower == claim_lower {
                    return Ok(());
                }
            }
            // Strict route under wildcard/parameterized: must end with
            // ".<suffix>" where the claim's suffix is the part after
            // `*.` or `{...}.`.
            (
                HostKind::Strict,
                picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
            ) => {
                let suffix = claim_lower
                    .split_once('.')
                    .map(|(_, s)| s.to_string())
                    .unwrap_or_default();
                let needle = format!(".{suffix}");
                if !suffix.is_empty() && host_lower.ends_with(&needle) {
                    return Ok(());
                }
            }
            // Wildcard route: must match a wildcard or parameterized
            // claim with identical suffix.
            (
                HostKind::Wildcard,
                picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
            ) => {
                let claim_suffix = claim_lower
                    .split_once('.')
                    .map(|(_, s)| s.to_string())
                    .unwrap_or_default();
                if claim_suffix == host_lower {
                    return Ok(());
                }
            }
            // Wildcard route under exact claim: not allowed (would
            // shadow other apps' subdomains the operator didn't claim).
            (HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
            (HostKind::Any, _) => unreachable!("handled above"),
        }
    }

    Err(RouteApiError::HostNotClaimed {
        host: host.to_string(),
        available_claims: claims.into_iter().map(|c| c.pattern).collect(),
    })
}

// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------

#[derive(Debug, thiserror::Error)]
pub enum RouteApiError {
    #[error("route conflicts with existing route ({reason})")]
    Conflict {
        conflicting_route: Box<Route>,
        reason: String,
    },

    #[error("invalid route: {0}")]
    Pattern(#[from] pattern::ParseError),

    #[error("bad request: {0}")]
    BadRequest(String),

    #[error("script not found: {0}")]
    ScriptNotFound(ScriptId),

    #[error("route not found: {0}")]
    RouteNotFound(Uuid),

    #[error("host {host:?} is not claimed by this app")]
    HostNotClaimed {
        host: String,
        available_claims: Vec<String>,
    },

    #[error("forbidden")]
    Forbidden,

    #[error("authorization repo error: {0}")]
    AuthzRepo(String),

    #[error("repository error: {0}")]
    Repo(#[from] ScriptRepositoryError),
}

impl From<AuthzDenied> for RouteApiError {
    fn from(d: AuthzDenied) -> Self {
        match d {
            AuthzDenied::Denied => Self::Forbidden,
            AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
        }
    }
}

impl IntoResponse for RouteApiError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            Self::Conflict {
                conflicting_route,
                reason,
            } => (
                StatusCode::CONFLICT,
                serde_json::json!({
                    "error": self.to_string(),
                    "reason": reason,
                    "conflicting_route": &**conflicting_route,
                }),
            ),
            Self::Pattern(_) | Self::BadRequest(_) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                serde_json::json!({ "error": self.to_string() }),
            ),
            Self::ScriptNotFound(_)
            | Self::RouteNotFound(_)
            | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
                StatusCode::NOT_FOUND,
                serde_json::json!({ "error": self.to_string() }),
            ),
            Self::Forbidden => (
                StatusCode::FORBIDDEN,
                serde_json::json!({ "error": self.to_string() }),
            ),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "route authz repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    serde_json::json!({ "error": "internal error" }),
                )
            }
            Self::HostNotClaimed {
                host,
                available_claims,
            } => (
                StatusCode::UNPROCESSABLE_ENTITY,
                serde_json::json!({
                    "error": self.to_string(),
                    "host": host,
                    "available_claims": available_claims,
                }),
            ),
            Self::Repo(ScriptRepositoryError::Conflict(_)) => (
                StatusCode::CONFLICT,
                serde_json::json!({ "error": self.to_string() }),
            ),
            Self::Repo(ScriptRepositoryError::Db(e)) => {
                tracing::error!(error = %e, "route admin db error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    serde_json::json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use picloud_shared::DispatchMode;
    use uuid::Uuid;

    fn route_with_path(path: &str) -> Route {
        Route {
            id: Uuid::new_v4(),
            app_id: Some(AppId::from(Uuid::new_v4())),
            group_id: None,
            script_id: ScriptId::from(Uuid::new_v4()),
            host_kind: HostKind::Any,
            host: String::new(),
            host_param_name: None,
            path_kind: PathKind::Exact,
            path: path.to_string(),
            method: None,
            dispatch_mode: DispatchMode::default(),
            enabled: true,
            sealed: false,
            created_at: chrono::Utc::now(),
        }
    }

    /// Wrap an app-owned route as its own `depth 0` effective row — the shape
    /// `list_effective` produces for an app-only install (no group templates).
    fn eff(route: &Route) -> EffectiveRoute {
        EffectiveRoute {
            effective_app_id: route.app_id.expect("test route is app-owned"),
            depth: 0,
            route: route.clone(),
        }
    }

    /// No route suppressions — the common case for these compile tests.
    fn no_suppress() -> std::collections::HashMap<(AppId, String), i32> {
        std::collections::HashMap::new()
    }

    /// Build an `EffectiveRoute` at an explicit chain depth (for suppression
    /// tests that need an inherited row).
    fn eff_at(route: &Route, effective_app_id: AppId, depth: i32) -> EffectiveRoute {
        EffectiveRoute {
            effective_app_id,
            depth,
            route: route.clone(),
        }
    }

    #[test]
    fn compile_effective_skips_uncompilable_rows_instead_of_failing() {
        // H1 regression guard: a stored route whose path is now reserved
        // (creatable before the case-insensitive reserved-prefix fix) must
        // be skipped, not abort the whole compile — otherwise one legacy
        // row bricks startup (the effective rebuild runs in `build_app`).
        let good_a = route_with_path("/ok");
        let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
        let good_b = route_with_path("/items");
        let rows = [eff(&good_a), eff(&bad), eff(&good_b)];

        let compiled = compile_effective_routes(&rows, &no_suppress());

        let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
        assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
        assert!(ids.contains(&good_a.id));
        assert!(ids.contains(&good_b.id));
        assert!(
            !ids.contains(&bad.id),
            "a reserved-path route must be skipped, never abort the compile"
        );
    }

    #[test]
    fn disabled_route_is_dropped_from_compiled_table() {
        // §4.3: a disabled route is excluded from the match table, so a
        // request to it 404s indistinguishably from an absent route.
        let active = route_with_path("/on");
        let mut disabled = route_with_path("/off");
        disabled.enabled = false;
        let compiled = compile_effective_routes(&[eff(&active), eff(&disabled)], &no_suppress());
        let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
        assert_eq!(ids, vec![active.id], "only the enabled route compiles");
    }

    #[test]
    fn nearest_owner_shadows_identical_binding() {
        // An app's own route (depth 0) and an ancestor-group template (depth 1)
        // with the SAME binding collapse to the nearer one; a different binding
        // coexists. Mirrors the live `group_route_templates` integration test
        // at the pure-compile layer.
        let app = AppId::from(Uuid::new_v4());
        let own = route_with_path("/x"); // app's own /x
        let mut template = route_with_path("/x"); // group template, same binding
        template.app_id = None;
        template.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4()));
        let other_template = route_with_path("/y"); // group template, different path

        let rows = [
            EffectiveRoute {
                effective_app_id: app,
                depth: 0,
                route: own.clone(),
            },
            EffectiveRoute {
                effective_app_id: app,
                depth: 1,
                route: template.clone(),
            },
            EffectiveRoute {
                effective_app_id: app,
                depth: 1,
                route: other_template.clone(),
            },
        ];
        let ids: Vec<Uuid> = compile_effective_routes(&rows, &no_suppress())
            .iter()
            .map(|c| c.route_id)
            .collect();
        assert!(ids.contains(&own.id), "the app's own /x must win");
        assert!(
            !ids.contains(&template.id),
            "the inherited /x template must be shadowed by the app's own"
        );
        assert!(
            ids.contains(&other_template.id),
            "a non-identical /y template must coexist"
        );
    }

    #[test]
    fn group_suppression_does_not_over_decline_a_nearer_descendants_own_route() {
        // Audit fix #3: a suppression declared at depth 2 (an ancestor group G)
        // must decline only routes inherited from ABOVE it (a far ancestor GG's
        // template at depth 3), NOT a nearer descendant group H's own
        // re-declaration of the same path at depth 1. Both /x routes carry
        // DIFFERENT bindings (GET vs POST) so shadowing doesn't hide the bug —
        // the suppression, keyed by path, is what would wrongly drop H's route.
        let app = AppId::from(Uuid::new_v4());
        let mut gg = route_with_path("/x"); // far ancestor's template
        gg.method = Some("GET".into());
        gg.app_id = None;
        gg.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4()));
        let mut h = route_with_path("/x"); // nearer descendant group's OWN route
        h.method = Some("POST".into());
        h.app_id = None;
        h.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4()));

        // Ordered nearest-first, as list_effective guarantees.
        let rows = [eff_at(&h, app, 1), eff_at(&gg, app, 3)];
        // G suppresses /x at depth 2.
        let mut suppressed = std::collections::HashMap::new();
        suppressed.insert((app, "/x".to_string()), 2);

        let ids: Vec<Uuid> = compile_effective_routes(&rows, &suppressed)
            .iter()
            .map(|c| c.route_id)
            .collect();
        assert!(
            !ids.contains(&gg.id),
            "the far-ancestor template (depth 3 > 2) must be declined"
        );
        assert!(
            ids.contains(&h.id),
            "the nearer descendant's own /x (depth 1 < 2) must NOT be over-declined"
        );
    }
}
