//! Control-plane HTTP surface. Mounted by the `picloud` all-in-one
//! binary under `/api/admin` and by the future split `picloud-manager`
//! binary at its own root.

use std::sync::Arc;

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Extension, Json, Router,
};
use picloud_shared::{
    AppId, ExecutionLog, ExecutionSource, GroupId, InstanceRole, Principal, Script, ScriptId,
    ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
};
use serde::Deserialize;

use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{
    ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
};
use crate::sandbox::{CeilingError, SandboxCeiling};

/// State shared by control-plane handlers. Separates concerns so the
/// manager can validate at upload time without depending on the
/// concrete executor-core types.
pub struct AdminState<R, L> {
    pub repo: Arc<R>,
    pub logs: Arc<L>,
    /// App lookups: validates `app_id` on create, resolves `?app=<slug>`
    /// filter on list. Trait-object so apps_repo can stay separate.
    pub apps: Arc<dyn AppRepository>,
    /// Phase 3.5 capability checks — every script handler resolves
    /// `AppRead/Write/LogRead(script.app_id)` against this repo after
    /// loading the resource.
    pub authz: Arc<dyn AuthzRepo>,
    pub validator: Arc<dyn ScriptValidator>,
    pub sandbox_ceiling: SandboxCeiling,
}

impl<R, L> Clone for AdminState<R, L> {
    fn clone(&self) -> Self {
        Self {
            repo: self.repo.clone(),
            logs: self.logs.clone(),
            apps: self.apps.clone(),
            authz: self.authz.clone(),
            validator: self.validator.clone(),
            sandbox_ceiling: self.sandbox_ceiling,
        }
    }
}

/// Build the admin router. The caller (binary) chooses where to mount
/// it (typically `Router::new().nest("/api/admin", admin_router(state))`).
pub fn admin_router<R, L>(state: AdminState<R, L>) -> Router
where
    R: ScriptRepository + 'static,
    L: ExecutionLogRepository + 'static,
{
    Router::new()
        .route(
            "/scripts",
            get(list_scripts::<R, L>).post(create_script::<R, L>),
        )
        .route(
            "/scripts/{id}",
            get(get_script::<R, L>)
                .put(update_script::<R, L>)
                .delete(delete_script::<R, L>),
        )
        .route("/scripts/{id}/logs", get(list_logs::<R, L>))
        .with_state(state)
}

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

#[derive(Debug, Deserialize)]
pub struct CreateScriptRequest {
    /// Owning app. Required since Phase 3b — scripts cannot exist
    /// outside an app. Use `/api/v1/admin/apps` to list known ids.
    pub app_id: AppId,
    pub name: String,
    pub description: Option<String>,
    pub source: String,
    /// v1.1.3: `endpoint` (default — handles HTTP routes / trigger
    /// targets) or `module` (library of fn/const imported by other
    /// scripts). Modules reject route binding and trigger creation.
    #[serde(default)]
    pub kind: ScriptKind,
    pub timeout_seconds: Option<i32>,
    pub memory_limit_mb: Option<i32>,
    /// Sandbox overrides; absent or empty `{}` means "use platform
    /// defaults". Each non-null field is checked against the admin
    /// ceiling at write time.
    #[serde(default)]
    pub sandbox: ScriptSandbox,
}

#[derive(Debug, Deserialize)]
pub struct ListScriptsQuery {
    /// Optional filter: list scripts belonging to a single app, by id
    /// or slug. Absent = all scripts across all apps (admin-global view).
    #[serde(default)]
    pub app: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct UpdateScriptRequest {
    pub name: Option<String>,
    // Double Option lets clients explicitly clear the description by
    // sending `"description": null`; an absent field leaves it alone.
    #[serde(default, deserialize_with = "deserialize_optional_optional")]
    #[allow(clippy::option_option)]
    pub description: Option<Option<String>>,
    pub source: Option<String>,
    pub timeout_seconds: Option<i32>,
    pub memory_limit_mb: Option<i32>,
    /// `Some(sandbox)` replaces the stored overrides wholesale (use
    /// `Some(ScriptSandbox::empty())` to clear them). Absent leaves
    /// the stored value unchanged.
    pub sandbox: Option<ScriptSandbox>,
    /// v1.1.3: `Some(kind)` changes the script's role. Transitions to
    /// `Module` are rejected if any routes or triggers still reference
    /// the script. `module → endpoint` is always allowed.
    pub kind: Option<ScriptKind>,
}

#[allow(clippy::option_option)]
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    Option::<String>::deserialize(d).map(Some)
}

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

async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
    State(state): State<AdminState<R, L>>,
    Extension(principal): Extension<Principal>,
    Query(q): Query<ListScriptsQuery>,
) -> Result<Json<Vec<Script>>, ApiError> {
    // Membership filter: `member` users see only scripts in apps they
    // belong to. `?app=` filters further by app and additionally
    // requires the member to belong to that app (the read check uses
    // the resource's app_id).
    if let Some(ident) = q.app {
        let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
        require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
        return Ok(Json(state.repo.list_for_app(app).await?));
    }
    if principal.instance_role == InstanceRole::Member {
        return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
    }
    Ok(Json(state.repo.list().await?))
}

/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
/// for redirects, but here we just need the live current id; if a
/// retired slug is given, we follow it to the current app silently.
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
    if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
        let id = AppId::from(uuid);
        apps.get_by_id(id)
            .await?
            .ok_or(ApiError::AppNotFound(ident.to_string()))?;
        return Ok(id);
    }
    let lookup = apps
        .get_by_slug_or_history(ident)
        .await?
        .ok_or(ApiError::AppNotFound(ident.to_string()))?;
    Ok(lookup.app.id)
}

/// Capability authorizing an operation on a script of either owner (Phase 4).
/// App-owned scripts map to their `App*` capability exactly as before; a
/// group-owned script maps to the group-scoped capability, resolved against
/// the group ancestor walk. An orphan row (neither owner — impossible under
/// the DB CHECK) reads as a missing id.
///
/// The `app`/`group` arguments are the capability constructors for each
/// owner, so one helper serves read / write / admin / log-read by passing
/// the matching pair.
fn script_cap(
    script: &Script,
    app: fn(AppId) -> Capability,
    group: fn(GroupId) -> Capability,
) -> Result<Capability, ApiError> {
    match script.owner() {
        Some(ScriptOwner::App(a)) => Ok(app(a)),
        Some(ScriptOwner::Group(g)) => Ok(group(g)),
        None => Err(ApiError::NotFound(script.id)),
    }
}

async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
    State(state): State<AdminState<R, L>>,
    Extension(principal): Extension<Principal>,
    Path(id): Path<ScriptId>,
) -> Result<Json<Script>, ApiError> {
    let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    require(
        state.authz.as_ref(),
        &principal,
        script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
    )
    .await?;
    Ok(Json(script))
}

async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
    State(state): State<AdminState<R, L>>,
    Extension(principal): Extension<Principal>,
    Json(input): Json<CreateScriptRequest>,
) -> Result<(StatusCode, Json<Script>), ApiError> {
    // Capability is bound to the *requested* app_id since there's no
    // resource to load yet. If the app doesn't exist we 422 below;
    // checking authz first means a Member trying to create against an
    // unknown app gets 403 (no enumeration of app existence).
    require(
        state.authz.as_ref(),
        &principal,
        Capability::AppWriteScript(input.app_id),
    )
    .await?;
    // v1.1.3: dispatch to the right validator based on declared kind.
    // Module bodies have stricter rules (no top-level statements) so
    // they need a separate gate; endpoints retain the parse-only path.
    let validated: ValidatedScript = if input.kind == ScriptKind::Module {
        if RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
            return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
                "{:?} is a reserved module name (shadows a built-in SDK namespace)",
                input.name
            ))));
        }
        state.validator.validate_module(&input.source)?
    } else {
        state.validator.validate(&input.source)?
    };
    state.sandbox_ceiling.check(&input.sandbox)?;
    // Refuse early if the app_id doesn't exist — a clean 422 beats a
    // raw FK violation surfacing as 500.
    if state.apps.get_by_id(input.app_id).await?.is_none() {
        return Err(ApiError::AppNotFound(input.app_id.to_string()));
    }
    let created = state
        .repo
        .create(NewScript {
            app_id: Some(input.app_id),
            group_id: None,
            name: input.name,
            description: input.description,
            source: input.source,
            kind: input.kind,
            timeout_seconds: input.timeout_seconds,
            memory_limit_mb: input.memory_limit_mb,
            sandbox: if input.sandbox.is_empty() {
                None
            } else {
                Some(input.sandbox)
            },
            // Scripts are created active; toggling is a dedicated path.
            enabled: true,
            imports: validated.imports,
        })
        .await?;
    Ok((StatusCode::CREATED, Json(created)))
}

/// Module names that would shadow a built-in stdlib / service namespace.
/// Rejected at create time so `import "kv" as foo` can never resolve to
/// a user-supplied module instead of (in a hypothetical future) the
/// real KV bridge — defense against author confusion, not a security
/// boundary (stdlib namespaces and module imports already live in
/// disjoint Rhai scopes).
pub(crate) const RESERVED_MODULE_NAMES: &[&str] = &[
    "log",
    "regex",
    "random",
    "time",
    "json",
    "base64",
    "hex",
    "url",
    "kv",
    "docs",
    "dead_letters",
    "http",
    "files",
    "pubsub",
    "secrets",
    "email",
    "users",
    "queue",
];

async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
    State(state): State<AdminState<R, L>>,
    Extension(principal): Extension<Principal>,
    Path(id): Path<ScriptId>,
    Json(input): Json<UpdateScriptRequest>,
) -> Result<Json<Script>, ApiError> {
    let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    require(
        state.authz.as_ref(),
        &principal,
        script_cap(
            &script,
            Capability::AppWriteScript,
            Capability::GroupScriptsWrite,
        )?,
    )
    .await?;

    // Effective post-update kind: explicit override > existing kind.
    let effective_kind = input.kind.unwrap_or(script.kind);

    // v1.1.3: reject `endpoint → module` if the script still has
    // routes or triggers bound to it. The reverse direction is always
    // allowed (a module can't have routes/triggers anyway, so the
    // transition can never strand users).
    if effective_kind == ScriptKind::Module && script.kind != ScriptKind::Module {
        let routes = state.repo.count_routes_for_script(id).await?;
        let triggers = state.repo.count_triggers_for_script(id).await?;
        if routes + triggers > 0 {
            return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
                "cannot change kind to module: script is referenced by {routes} route(s) and {triggers} trigger(s); detach them first"
            ))));
        }
        if RESERVED_MODULE_NAMES.contains(&script.name.as_str()) {
            return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
                "{:?} is a reserved module name (shadows a built-in SDK namespace)",
                script.name
            ))));
        }
    }

    // v1.1.3: re-validate using the effective kind so endpoint → module
    // transitions with a fresh source enforce the module shape rules.
    // Source-less edits (name/description only) don't re-validate.
    let imports_for_patch: Option<Vec<String>> = if let Some(src) = input.source.as_deref() {
        let validated = if effective_kind == ScriptKind::Module {
            state.validator.validate_module(src)?
        } else {
            state.validator.validate(src)?
        };
        Some(validated.imports)
    } else {
        None
    };

    if let Some(sb) = input.sandbox.as_ref() {
        state.sandbox_ceiling.check(sb)?;
    }
    let updated = state
        .repo
        .update(
            id,
            ScriptPatch {
                name: input.name,
                description: input.description,
                source: input.source,
                timeout_seconds: input.timeout_seconds,
                memory_limit_mb: input.memory_limit_mb,
                sandbox: input.sandbox,
                kind: input.kind,
                enabled: None,
                imports: imports_for_patch,
            },
        )
        .await?;
    Ok(Json(updated))
}

async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
    State(state): State<AdminState<R, L>>,
    Extension(principal): Extension<Principal>,
    Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> {
    let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    // Delete is gated tighter than Save for app-owned scripts: editors can
    // edit but only app_admin / instance admin / owner can remove them (§11.6).
    // A group-owned script (Phase 4) has no group-admin-tier script cap, so it
    // is gated at `GroupScriptsWrite` (editor+ on the group) — the same tier
    // that created it; group deletion itself stays group_admin-gated elsewhere.
    require(
        state.authz.as_ref(),
        &principal,
        script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
    )
    .await?;
    state.repo.delete(id).await?;
    Ok(StatusCode::NO_CONTENT)
}

#[derive(Debug, Deserialize)]
pub struct LogsQuery {
    #[serde(default = "default_limit")]
    pub limit: i64,
    /// F-P-005: keyset cursor (`<rfc3339>_<uuid>`). Replaces the OFFSET
    /// path that scanned + discarded N rows per page. Absent for the
    /// first page. The legacy `offset` query param is accepted but
    /// silently ignored to keep older dashboards from 400'ing.
    #[serde(default)]
    pub cursor: Option<String>,
    /// Legacy field — accepted and ignored so older dashboards don't 400.
    #[serde(default, rename = "offset")]
    #[allow(dead_code)]
    pub legacy_offset: Option<i64>,
    /// Optional origin filter (`http`, `kv`, `cron`, `invoke`, …). Absent
    /// → all sources. An unrecognized value is a 422 (see `list_logs`).
    #[serde(default)]
    pub source: Option<String>,
}

const fn default_limit() -> i64 {
    50
}

async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
    State(state): State<AdminState<R, L>>,
    Extension(principal): Extension<Principal>,
    Path(id): Path<ScriptId>,
    axum::extract::Query(q): axum::extract::Query<LogsQuery>,
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
    let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    require(
        state.authz.as_ref(),
        &principal,
        script_cap(
            &script,
            Capability::AppLogRead,
            Capability::GroupScriptsRead,
        )?,
    )
    .await?;
    // Cap to keep the dashboard responsive; the data plane writes are
    // unbounded over time so a paged read is the only sane default.
    let limit = q.limit.clamp(1, 200);
    let cursor = q
        .cursor
        .as_deref()
        .and_then(crate::repo::ExecutionLogCursor::decode);
    let source = match q.source.as_deref() {
        None | Some("" | "all") => None,
        Some(s) => Some(
            ExecutionSource::from_wire(s)
                .ok_or_else(|| ApiError::BadRequest(format!("unknown log source: {s:?}")))?,
        ),
    };
    let logs = state
        .logs
        .list_for_script(id, limit, cursor, source)
        .await?;
    Ok(Json(logs))
}

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

#[derive(Debug, thiserror::Error)]
pub enum ApiError {
    #[error("script not found: {0}")]
    NotFound(ScriptId),

    #[error("app not found: {0}")]
    AppNotFound(String),

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

    #[error("conflict: {0}")]
    Conflict(String),

    #[error("invalid script: {0}")]
    Invalid(#[from] ValidationError),

    #[error("{0}")]
    Ceiling(#[from] CeilingError),

    #[error("forbidden")]
    Forbidden,

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

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

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

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
            Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
                (StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
            }
            Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
            Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "authz repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "internal error".to_string(),
                )
            }
            Self::Repo(ScriptRepositoryError::NotFound(_)) => {
                (StatusCode::NOT_FOUND, self.to_string())
            }
            Self::Repo(ScriptRepositoryError::Conflict(_)) => {
                (StatusCode::CONFLICT, self.to_string())
            }
            Self::Repo(ScriptRepositoryError::Db(e)) => {
                tracing::error!(error = %e, "manager db error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "internal error".to_string(),
                )
            }
        };
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}
