//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
//!
//! All endpoints are guarded by `require_admin`. Per-app permissions
//! are deferred (every authenticated admin can act on every app); the
//! middleware seam exists for when that lands.
//!
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
//! list rejected. Slug renames record the old slug in
//! `app_slug_history` for permanent 301 redirects; reclaiming a
//! historical slug requires `"force_takeover": true` in the request.

use std::sync::Arc;

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain, RouteTable};
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;

use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository;

const SLUG_MIN: usize = 1;
const SLUG_MAX: usize = 63;
const RESERVED_SLUGS: &[&str] = &[
    "new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
];

#[derive(Clone)]
pub struct AppsState {
    pub apps: Arc<dyn AppRepository>,
    pub domains: Arc<dyn AppDomainRepository>,
    pub routes: Arc<dyn RouteRepository>,
    /// Cached host → app_id lookup; replaced after every domain CRUD
    /// operation so the orchestrator sees changes immediately.
    pub domain_table: Arc<AppDomainTable>,
    /// §11 tail: the route match snapshot. Creating/deleting an app changes
    /// which group route TEMPLATES are inherited, so it is rebuilt here
    /// (full-live invalidation) — mirroring the domain_table refresh.
    pub route_table: Arc<RouteTable>,
    /// Capability gate — Phase 3.5.
    pub authz: Arc<dyn AuthzRepo>,
    /// Group tree — resolves an app's parent group at create time
    /// (defaults to the instance root).
    pub groups: Arc<dyn GroupRepository>,
    /// §4.5 M5: creating/deleting an app changes which ancestor-group stateful
    /// templates it inherits, so its materialized cron/queue copies are
    /// reconciled here (full-live, mirroring the route_table rebuild).
    pub pool: sqlx::PgPool,
}

pub fn apps_router(state: AppsState) -> Router {
    Router::new()
        .route("/apps", get(list_apps).post(create_app))
        .route(
            "/apps/{id_or_slug}",
            get(get_app).patch(patch_app).delete(delete_app),
        )
        .route("/apps/{id_or_slug}/slug:check", post(slug_check))
        .route(
            "/apps/{id_or_slug}/domains",
            get(list_domains).post(create_domain),
        )
        .route(
            "/apps/{id_or_slug}/domains/{domain_id}",
            delete(delete_domain),
        )
        .with_state(state)
}

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

#[derive(Debug, Serialize)]
pub struct AppDto {
    #[serde(flatten)]
    pub app: App,
}

#[derive(Debug, Deserialize)]
pub struct CreateAppRequest {
    pub slug: String,
    pub name: String,
    pub description: Option<String>,
    /// Parent group (slug or id). Defaults to the instance root group when
    /// omitted — every app has a parent from day one (§9).
    #[serde(default)]
    pub group: Option<String>,
    /// Set to `true` to consume an existing `app_slug_history` row for
    /// the requested slug (breaking old redirects).
    #[serde(default)]
    pub force_takeover: bool,
}

#[derive(Debug, Deserialize)]
pub struct PatchAppRequest {
    pub name: Option<String>,
    #[serde(default, deserialize_with = "deserialize_optional_optional")]
    #[allow(clippy::option_option)]
    pub description: Option<Option<String>>,
    pub slug: Option<String>,
    #[serde(default)]
    pub force_takeover: bool,
}

#[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)
}

#[derive(Debug, Deserialize)]
pub struct SlugCheckRequest {
    pub new_slug: String,
}

#[derive(Debug, Serialize)]
pub struct SlugCheckResponse {
    pub ok: bool,
    pub conflict_kind: Option<&'static str>,
    pub current_app: Option<App>,
    pub reason: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct CreateDomainRequest {
    pub pattern: String,
}

/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
/// a cascading delete that also removes every script in the app (and
/// thereby their routes and execution logs). Without it the request is
/// rejected when the app still contains scripts.
#[derive(Debug, Default, Deserialize)]
pub struct DeleteAppQuery {
    #[serde(default)]
    pub force: bool,
}

#[derive(Debug, Serialize)]
pub struct AppLookupResponse {
    #[serde(flatten)]
    pub app: App,
    /// When the operator hits the API with a retired slug, this points
    /// at the live slug so dashboards can redirect.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub redirect_to: Option<String>,
    /// The caller's role on this app, used by the dashboard to decide
    /// whether to render admin-only surfaces (Members tab, settings).
    /// `Owner` and `Admin` both map to `app_admin` (implicit per
    /// blueprint §11.6); `Member` carries its explicit
    /// `app_members.role`.
    pub my_role: Option<AppRole>,
}

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

async fn list_apps(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<App>>, AppsApiError> {
    // Member callers see only apps they're a member of; owner/admin
    // see everything. Filter at the SQL layer (not just in the
    // dashboard) — that's the strict-isolation guarantee from §11.6.
    let apps = if principal.instance_role == InstanceRole::Member {
        s.apps.list_for_user(principal.user_id).await?
    } else {
        s.apps.list().await?
    };
    Ok(Json(apps))
}

async fn create_app(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
    Json(input): Json<CreateAppRequest>,
) -> Result<(StatusCode, Json<App>), AppsApiError> {
    require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
    validate_slug(&input.slug)?;

    // Resolve the parent group: an explicit `group` (slug or id) or the
    // instance root by default. Placing an app under a specific group
    // additionally requires group-write there.
    let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
    if input.group.is_some() {
        require(
            s.authz.as_ref(),
            &principal,
            Capability::GroupWrite(parent.id),
        )
        .await?;
    }

    // Historical-slug check before insert: if the slug is in history
    // and the caller hasn't asked to force takeover, surface a clean
    // 409 so the dashboard can present a "this will break old links"
    // confirmation.
    if !input.force_takeover {
        if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
            return Err(AppsApiError::SlugInHistory(Box::new(current)));
        }
    }

    let created = if input.force_takeover {
        s.apps
            .create_with_takeover(
                &input.slug,
                &input.name,
                input.description.as_deref(),
                parent.id,
            )
            .await?
    } else {
        s.apps
            .create(
                &input.slug,
                &input.name,
                input.description.as_deref(),
                parent.id,
            )
            .await?
    };
    // The new app may sit under a group that owns route TEMPLATES — make them
    // servable immediately (full-live invalidation).
    refresh_route_cache(&s).await;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn get_app(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<AppLookupResponse>, AppsApiError> {
    let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppRead(lookup.app.id),
    )
    .await?;
    let redirect_to = if lookup.redirected {
        Some(lookup.app.slug.clone())
    } else {
        None
    };
    let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
    Ok(Json(AppLookupResponse {
        app: lookup.app,
        redirect_to,
        my_role,
    }))
}

/// Compute the caller's effective `AppRole` on a specific app. Mirrors
/// the implicit-grant logic in `authz::role_grants` but returns the
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
/// consults `app_members`.
async fn compute_my_role(
    authz: &dyn AuthzRepo,
    principal: &Principal,
    app_id: AppId,
) -> Result<Option<AppRole>, AppsApiError> {
    match principal.instance_role {
        InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
        // Effective role: folds in inherited group memberships so the
        // dashboard badge reflects what the caller can actually do.
        InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
    }
}

/// Resolve an optional group identifier (slug or UUID) to a group,
/// defaulting to the instance root group when `None`.
async fn resolve_group(
    groups: &dyn GroupRepository,
    ident: Option<&str>,
) -> Result<picloud_shared::Group, AppsApiError> {
    match ident {
        None => groups
            .get_by_slug(ROOT_GROUP_SLUG)
            .await?
            .ok_or_else(|| AppsApiError::GroupNotFound(ROOT_GROUP_SLUG.to_string())),
        Some(ident) => {
            let found = if let Ok(uuid) = ident.parse::<Uuid>() {
                groups.get_by_id(uuid.into()).await?
            } else {
                groups.get_by_slug(ident).await?
            };
            found.ok_or_else(|| AppsApiError::GroupNotFound(ident.to_string()))
        }
    }
}

async fn patch_app(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<PatchAppRequest>,
) -> Result<Json<App>, AppsApiError> {
    let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppAdmin(current.id),
    )
    .await?;

    // Edits to name/description go first (separate from rename so we
    // don't conflate the two errors).
    let after_meta = if input.name.is_some() || input.description.is_some() {
        s.apps
            .update(
                current.id,
                input.name.as_deref(),
                input.description.as_ref().map(|d| d.as_deref()),
            )
            .await?
    } else {
        current
    };

    // Slug rename is a separate operation; the rename method does its
    // own history bookkeeping in a transaction.
    let after_rename = if let Some(new_slug) = input.slug.as_deref() {
        validate_slug(new_slug)?;
        match s
            .apps
            .rename_slug(after_meta.id, new_slug, input.force_takeover)
            .await
        {
            Ok(app) => app,
            Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
                if let Some(current) = s.apps.slug_in_history(new_slug).await? {
                    return Err(AppsApiError::SlugInHistory(Box::new(current)));
                }
                return Err(AppsApiError::Conflict(msg));
            }
            Err(e) => return Err(e.into()),
        }
    } else {
        after_meta
    };

    Ok(Json(after_rename))
}

async fn delete_app(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Query(q): Query<DeleteAppQuery>,
) -> Result<StatusCode, AppsApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
    require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;

    if q.force {
        s.apps.delete_cascade(app.id).await?;
    } else {
        // Soft pre-check for a clean error; the DB FK is the real guard
        // (ON DELETE RESTRICT on scripts.app_id).
        let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
        if n_scripts > 0 {
            return Err(AppsApiError::HasScripts(n_scripts));
        }
        s.apps.delete(app.id).await?;
    }
    refresh_domain_cache(&s).await?;
    // Drop the deleted app's slice (and any routes it owned) from the snapshot.
    refresh_route_cache(&s).await;
    Ok(StatusCode::NO_CONTENT)
}

async fn slug_check(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<SlugCheckRequest>,
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
    require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
    match validate_slug(&input.new_slug) {
        Err(AppsApiError::InvalidSlug(reason)) => {
            return Ok(Json(SlugCheckResponse {
                ok: false,
                conflict_kind: Some("invalid"),
                current_app: None,
                reason: Some(reason),
            }));
        }
        Err(other) => return Err(other),
        Ok(()) => {}
    }
    if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
        return Ok(Json(SlugCheckResponse {
            ok: false,
            conflict_kind: Some("current"),
            current_app: Some(app),
            reason: Some("another app currently uses this slug".into()),
        }));
    }
    if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
        return Ok(Json(SlugCheckResponse {
            ok: false,
            conflict_kind: Some("historical"),
            current_app: Some(app),
            reason: Some("slug is a retired redirect; using it will break old links".into()),
        }));
    }
    Ok(Json(SlugCheckResponse {
        ok: true,
        conflict_kind: None,
        current_app: None,
        reason: None,
    }))
}

async fn list_domains(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
    require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
    Ok(Json(s.domains.list_for_app(app.id).await?))
}

async fn create_domain(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateDomainRequest>,
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageDomains(app.id),
    )
    .await?;
    let parsed = pattern::parse_app_domain(&input.pattern)?;
    let created = s
        .domains
        .create(NewAppDomain {
            app_id: app.id,
            pattern: input.pattern,
            shape: parsed.shape,
            shape_key: parsed.shape_key,
        })
        .await?;
    refresh_domain_cache(&s).await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn delete_domain(
    State(s): State<AppsState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppsApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::AppManageDomains(app.id),
    )
    .await?;
    let Some(domain) = s.domains.get(domain_id).await? else {
        return Err(AppsApiError::DomainNotFound(domain_id));
    };
    if domain.app_id != app.id {
        return Err(AppsApiError::DomainNotFound(domain_id));
    }

    // Guard: routes inside this app may reference this exact host
    // pattern. The host-kind on the route is `strict` or `wildcard`
    // (Any routes don't pin a specific host). We block deletion in
    // either case and let the operator clean up first.
    let strict = s
        .routes
        .count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
        .await?;
    let wild_suffix = domain
        .pattern
        .split_once('.')
        .map(|(_, s)| s.to_string())
        .unwrap_or_default();
    let wild = if wild_suffix.is_empty() {
        0
    } else {
        s.routes
            .count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
            .await?
    };
    if strict + wild > 0 {
        return Err(AppsApiError::DomainHasRoutes(strict + wild));
    }

    s.domains.delete(domain_id).await?;
    refresh_domain_cache(&s).await?;
    Ok(StatusCode::NO_CONTENT)
}

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

async fn resolve_app(
    apps: &dyn AppRepository,
    ident: &str,
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
    crate::app_repo::resolve_app(apps, ident)
        .await?
        .ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
}

fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
    if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
        return Err(AppsApiError::InvalidSlug(format!(
            "slug length must be between {SLUG_MIN} and {SLUG_MAX}"
        )));
    }
    if !slug
        .chars()
        .next()
        .is_some_and(|c| c.is_ascii_alphanumeric())
    {
        return Err(AppsApiError::InvalidSlug(
            "slug must start with [a-z0-9]".into(),
        ));
    }
    for c in slug.chars() {
        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
            return Err(AppsApiError::InvalidSlug(
                "slug may only contain lowercase letters, digits, and '-'".into(),
            ));
        }
    }
    if RESERVED_SLUGS.contains(&slug) {
        return Err(AppsApiError::InvalidSlug(format!(
            "slug {slug:?} is reserved for system use"
        )));
    }
    Ok(())
}

/// Rebuild the in-memory host → app_id cache used by the orchestrator.
/// Called after every domain CRUD operation.
/// §11 tail: rebuild the route match snapshot after a tree mutation that
/// changes route inheritance (an app gained/lost its place in the group tree,
/// so the set of ancestor-group route TEMPLATES it serves changed). Best-effort
/// to mirror the apply path — a failure leaves a stale-but-self-healing table
/// rather than failing the committed mutation.
async fn refresh_route_cache(state: &AppsState) {
    if let Err(e) =
        crate::route_admin::rebuild_route_table(state.routes.as_ref(), &state.route_table).await
    {
        tracing::warn!(error = %e, "apps: route table refresh failed; it will self-heal");
    }
    // §4.5 M5: reconcile materialized stateful-template copies for the changed
    // app set (best-effort; self-heals on the next mutation).
    match crate::materialize::rematerialize_stateful_templates(&state.pool).await {
        Ok(warnings) => {
            for w in warnings {
                tracing::warn!(warning = %w, "apps: stateful-template materialization warning");
            }
        }
        Err(e) => {
            tracing::warn!(error = %e, "apps: stateful-template materialization failed; it will self-heal");
        }
    }
}

pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
    let all = state.domains.list_all().await?;
    let compiled = all
        .into_iter()
        .filter_map(|d| {
            // Parse the stored pattern; skip on parse error rather than
            // poisoning the entire cache. The handlers reject bad input,
            // so this is purely defensive against a future migration
            // that loosens the constraints.
            pattern::parse_app_domain(&d.pattern)
                .ok()
                .map(|p| CompiledAppDomain {
                    app_id: d.app_id,
                    pattern: p.pattern,
                    shape_key: p.shape_key,
                })
        })
        .collect();
    state.domain_table.replace(compiled);
    Ok(())
}

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

#[derive(Debug, thiserror::Error)]
pub enum AppsApiError {
    #[error("app not found: {0}")]
    AppNotFound(String),

    #[error("group not found: {0}")]
    GroupNotFound(String),

    #[error("domain not found: {0}")]
    DomainNotFound(Uuid),

    #[error("invalid slug: {0}")]
    InvalidSlug(String),

    // Boxed: `App` is large enough to trip clippy::result_large_err on
    // every handler returning `Result<_, AppsApiError>`.
    #[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
    SlugInHistory(Box<App>),

    #[error("app still contains {0} script(s); delete or move them first")]
    HasScripts(i64),

    #[error("domain has {0} route(s) bound to it; delete the routes first")]
    DomainHasRoutes(i64),

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

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

    #[error("forbidden")]
    Forbidden,

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

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

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

impl From<AuthzError> for AppsApiError {
    fn from(e: AuthzError) -> Self {
        Self::AuthzRepo(e.to_string())
    }
}

impl From<crate::group_repo::GroupRepositoryError> for AppsApiError {
    fn from(e: crate::group_repo::GroupRepositoryError) -> Self {
        use crate::group_repo::GroupRepositoryError as G;
        match e {
            G::NotFound(id) => Self::GroupNotFound(id.to_string()),
            G::Conflict(msg) => Self::Conflict(msg),
            G::Db(e) => Self::Repo(ScriptRepositoryError::Db(e)),
        }
    }
}

impl IntoResponse for AppsApiError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            Self::AppNotFound(_)
            | Self::GroupNotFound(_)
            | Self::DomainNotFound(_)
            | Self::Repo(ScriptRepositoryError::NotFound(_)) => {
                (StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
            }
            Self::InvalidSlug(_) | Self::Pattern(_) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": self.to_string() }),
            ),
            Self::SlugInHistory(current) => (
                StatusCode::CONFLICT,
                json!({
                    "error": self.to_string(),
                    "conflict_kind": "historical",
                    "current_app": current,
                }),
            ),
            Self::HasScripts(n) => (
                StatusCode::CONFLICT,
                json!({ "error": self.to_string(), "script_count": n }),
            ),
            Self::DomainHasRoutes(n) => (
                StatusCode::CONFLICT,
                json!({ "error": self.to_string(), "route_count": n }),
            ),
            Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
                (StatusCode::CONFLICT, json!({ "error": self.to_string() }))
            }
            Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "apps authz repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Repo(ScriptRepositoryError::Db(e)) => {
                tracing::error!(error = %e, "apps api db error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}
