use std::{collections::HashMap, path::PathBuf};
use shellexpand;

use axum::{
    Json,
    extract::{Extension, Form, Path, Query, State},
    http::{HeaderMap, HeaderValue, StatusCode, header},
    response::{Html, IntoResponse, Redirect, Response},
};
use serde::Deserialize;
use serde_json::{json, Value};
use tera::Context;

use crate::AppState;
use super::{context as ctx, error::UiError, extract_session_cookie, session::{SessionData, UiPerms}};

// ── Render helper ─────────────────────────────────────────────────────────────

fn render(app: &AppState, template: &str, tc: &Context) -> Result<Html<String>, UiError> {
    let ui = app.ui.as_ref().ok_or(UiError::NotConfigured)?;
    let html = ui.render(template, tc)?;
    Ok(Html(html))
}

fn base_ctx(app: &AppState) -> Context {
    let mut c = Context::new();
    c.insert("project_name", &app.project_name);
    c.insert("nats_enabled", &app.bus.is_some());
    c.insert("jwt_enabled", &app.jwt.is_some());
    let tool_names: Vec<&str> = app.registry.list().iter().map(|t| t.name).collect();
    c.insert("tool_names", &tool_names);
    c.insert("ws_name", "");
    c.insert("logo", "");
    c.insert("logo_dark", "");
    c.insert("daemon_version", env!("CARGO_PKG_VERSION"));
    let now_hms = chrono::Utc::now().format("%H:%M:%S").to_string();
    c.insert("now_hms", &now_hms);
    c
}

fn ws_ctx(app: &AppState, ws_name: &str, active_tab: &str) -> Context {
    let mut c = base_ctx(app);
    c.insert("ws_name", ws_name);
    c.insert("ws_active_tab", active_tab);
    let empty: Vec<String> = vec![];
    c.insert("ws_envs", &empty);
    c.insert("ws_has_clusters", &false);
    c.insert("ws_has_workflows", &false);
    c.insert("current_env", "");
    c
}

fn perm_ctx(perms: &UiPerms, c: &mut Context) {
    c.insert("perm_sub",   &perms.subject);
    c.insert("perm_role",  perms.role_label());
    c.insert("can_view",    &perms.can_view);
    c.insert("can_operate", &perms.can_operate);
    c.insert("can_admin",   &perms.can_admin);
}

// ── Workspace env helpers ─────────────────────────────────────────────────────

/// True if any infra env has a `clusters/` subdirectory.
fn ws_has_clusters(ws_path: &str, envs: &[String]) -> bool {
    !ws_path.is_empty() && envs.iter().any(|env| {
        std::path::Path::new(ws_path).join("infra").join(env).join("clusters").is_dir()
    })
}

/// True if any infra env has a `workflows/` subdirectory.
fn ws_has_workflows(ws_path: &str, envs: &[String]) -> bool {
    !ws_path.is_empty() && envs.iter().any(|env| {
        std::path::Path::new(ws_path).join("infra").join(env).join("workflows").is_dir()
    })
}

struct WsEnvInfo {
    envs: Vec<String>,
    has_clusters: bool,
    has_workflows: bool,
}

/// Priority rank for aggregating component states across servers.
/// Higher = worse; we surface the worst state seen on any server.
fn state_rank(s: &str) -> u8 {
    match s {
        "failed"    => 4,
        "running"   => 3,
        "pending"   => 2,
        "completed" => 1,
        _           => 0,
    }
}

struct EnvStates {
    /// comp_name_underscored → worst state across all servers
    components: HashMap<String, String>,
    /// server_name → worst state across all its taskservs
    servers: HashMap<String, String>,
    /// comp_name_underscored → primary server hosting it (first seen)
    comp_server: HashMap<String, String>,
}

/// Read `.provisioning-state.ncl` for a single infra env.
/// Returns component states (underscore-normalised) and per-server aggregate states.
async fn load_env_states(
    ws_path: &str,
    env: &str,
    import_path: Option<&str>,
) -> EnvStates {
    let mut comp: HashMap<String, String> = HashMap::new();
    let mut srv: HashMap<String, String> = HashMap::new();
    let mut comp_srv: HashMap<String, String> = HashMap::new();

    if ws_path.is_empty() {
        return EnvStates { components: comp, servers: srv, comp_server: comp_srv };
    }

    let state_file = std::path::Path::new(ws_path)
        .join("infra").join(env).join(".provisioning-state.ncl");
    if !state_file.exists() {
        return EnvStates { components: comp, servers: srv, comp_server: comp_srv };
    }

    let exported = ctx::ncl_export(&state_file, import_path).await;
    let Some(servers) = exported["servers"].as_object() else {
        return EnvStates { components: comp, servers: srv, comp_server: comp_srv };
    };

    for (srv_name, srv_val) in servers {
        let Some(taskservs) = srv_val["taskservs"].as_object() else { continue };
        for (ts_name, ts_val) in taskservs {
            let state = ts_val["state"].as_str().unwrap_or("");
            let rank = state_rank(state);

            let comp_key = ts_name.replace('-', "_");
            let ce = comp.entry(comp_key.clone()).or_insert_with(|| state.to_string());
            if rank > state_rank(ce) { *ce = state.to_string(); }

            comp_srv.entry(comp_key).or_insert_with(|| srv_name.clone());

            let se = srv.entry(srv_name.clone()).or_insert_with(|| state.to_string());
            if rank > state_rank(se) { *se = state.to_string(); }
        }
    }

    EnvStates { components: comp, servers: srv, comp_server: comp_srv }
}


/// Read `infra/{env}/servers.ncl` → hostname → `{role, scale}` JSON.
/// Non-fatal: returns empty map when the file is absent or nickel fails.
async fn load_infra_server_roles(
    ws_path: &str,
    env: &str,
    import_path: Option<&str>,
) -> HashMap<String, Value> {
    let mut map = HashMap::new();
    if ws_path.is_empty() || env.is_empty() { return map; }
    let servers_ncl = std::path::Path::new(ws_path)
        .join("infra").join(env).join("servers.ncl");
    if !servers_ncl.exists() { return map; }
    let exported = ctx::ncl_export(&servers_ncl, import_path).await;
    let Some(srvs) = exported["servers"].as_array() else { return map };
    for srv in srvs {
        let Some(hostname) = srv["hostname"].as_str() else { continue };
        let role     = srv.get("role").and_then(|r| r.as_str()).unwrap_or("").to_string();
        let scale    = srv.get("scale").cloned().unwrap_or(Value::Null);
        let ssh_key  = srv.get("ssh_key_path").and_then(|v| v.as_str()).unwrap_or("~/.ssh/htz_ops").to_string();
        let ssh_user = srv.get("installer_user").and_then(|v| v.as_str()).unwrap_or("root").to_string();
        let floating_ip_ncl = srv.get("floating_ip").and_then(|v| v.as_str()).unwrap_or("").to_string();
        let not_use  = srv.get("not_use").and_then(|v| v.as_bool()).unwrap_or(false);
        let additional_volumes = srv
            .get("storage")
            .and_then(|s| s.get("additional_volumes"))
            .cloned()
            .unwrap_or(Value::Array(vec![]));
        map.insert(hostname.to_string(), json!({
            "role": role, "scale": scale, "ssh_key": ssh_key, "ssh_user": ssh_user,
            "floating_ip_ncl": floating_ip_ncl, "not_use": not_use,
            "additional_volumes": additional_volumes,
        }));
    }
    map
}

/// Read `infra/{env}/.servers-state.json` → hostname → cached state JSON.
async fn load_servers_cache(ws_path: &str, env: &str) -> HashMap<String, Value> {
    let mut map = HashMap::new();
    if ws_path.is_empty() || env.is_empty() { return map; }
    let cache_path = std::path::Path::new(ws_path)
        .join("infra").join(env).join(".servers-state.json");
    let Ok(bytes) = tokio::fs::read(&cache_path).await else { return map };
    let Ok(v) = serde_json::from_slice::<Value>(&bytes) else { return map };
    let Some(obj) = v.as_object() else { return map };
    for (k, v) in obj {
        map.insert(k.clone(), v.clone());
    }
    map
}

/// Read `infra/{env}/.provisioning-state.ncl` and extract per-server taskserv states.
/// Returns a map: hostname → array of `{name, state, ended_at}`.
async fn load_provisioning_taskservs(ws_path: &str, env: &str) -> HashMap<String, Value> {
    let mut map = HashMap::new();
    if ws_path.is_empty() || env.is_empty() { return map; }
    let state_path = std::path::Path::new(ws_path)
        .join("infra").join(env).join(".provisioning-state.ncl");
    if !state_path.exists() { return map; }
    // No import path needed — .provisioning-state.ncl has no imports.
    let exported = ctx::ncl_export(&state_path, None).await;
    let Some(servers_obj) = exported.get("servers").and_then(|v| v.as_object()) else { return map };
    for (hostname, srv_val) in servers_obj {
        let Some(taskservs) = srv_val.get("taskservs").and_then(|v| v.as_object()) else { continue };
        let items: Vec<Value> = taskservs.iter().map(|(name, ts)| {
            json!({
                "name":       name,
                "state":      ts.get("state").and_then(|v| v.as_str()).unwrap_or(""),
                "started_at": ts.get("started_at").and_then(|v| v.as_str()).unwrap_or(""),
                "ended_at":   ts.get("ended_at").and_then(|v| v.as_str()).unwrap_or(""),
                "operation":  ts.get("operation").and_then(|v| v.as_str()).unwrap_or(""),
            })
        }).collect();
        map.insert(hostname.clone(), Value::Array(items));
    }
    map
}

/// Fetch live hcloud server status for a set of server names.
/// Returns `server_name → hcloud_status` (e.g. "running", "off", "starting").
/// Non-fatal: returns empty map on any error (hcloud not configured, network down, etc.).
async fn hcloud_server_statuses(app: &AppState, names: &[String]) -> HashMap<String, String> {
    if names.is_empty() { return HashMap::new(); }
    let Ok(resp) = ctx::invoke_tool(app, "server_list", json!({})).await else {
        return HashMap::new();
    };
    let Some(items) = resp["items"].as_array() else { return HashMap::new() };
    items.iter().filter_map(|srv| {
        let name   = srv["name"].as_str()?;
        let status = srv["status"].as_str().unwrap_or("unknown");
        if names.iter().any(|n| n == name) {
            Some((name.to_string(), status.to_string()))
        } else {
            None
        }
    }).collect()
}

fn build_overview_components(
    raw: Option<&Vec<Value>>,
    ncl_states: &HashMap<String, String>,
    all_running: bool,
) -> Vec<Value> {
    let Some(arr) = raw else { return vec![] };
    arr.iter().map(|c| {
        let cname = c.as_str().unwrap_or("");
        let ncl = ncl_states.get(cname).cloned().unwrap_or_default();
        let state = if !ncl.is_empty() {
            ncl
        } else if all_running {
            "running".to_string()
        } else {
            String::new()
        };
        json!({ "name": cname, "state": state })
    }).collect()
}

fn build_overview_servers(
    ncl_servers: &HashMap<String, String>,
    hcloud: &HashMap<String, String>,
) -> Vec<Value> {
    ncl_servers.keys().map(|srv_name| {
        let live = hcloud.get(srv_name).cloned().unwrap_or_default();
        let ncl  = ncl_servers.get(srv_name).cloned().unwrap_or_default();
        let state = if !live.is_empty() { live } else { ncl };
        json!({ "name": srv_name, "state": state })
    }).collect()
}

/// Returns only environments that have `infra/<env>/settings.ncl`.
fn ws_infra_envs(ws: &Value) -> Vec<String> {
    let ws_path = ws["path"].as_str().unwrap_or("");
    ws["environments"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|e| e["name"].as_str().map(str::to_string))
                .filter(|name| {
                    if ws_path.is_empty() { return true; }
                    std::path::Path::new(ws_path)
                        .join("infra").join(name).join("settings.ncl")
                        .exists()
                })
                .collect()
        })
        .unwrap_or_default()
}

fn ws_env_info(ws: Option<&Value>) -> WsEnvInfo {
    let Some(ws) = ws else {
        return WsEnvInfo { envs: vec![], has_clusters: false, has_workflows: false };
    };
    let ws_path = ws["path"].as_str().unwrap_or("");
    let envs = ws_infra_envs(ws);
    let has_clusters = ws_has_clusters(ws_path, &envs);
    let has_workflows = ws_has_workflows(ws_path, &envs);
    WsEnvInfo { envs, has_clusters, has_workflows }
}

fn insert_ws_env_info(c: &mut Context, info: &WsEnvInfo) {
    c.insert("ws_envs", &info.envs);
    c.insert("ws_has_clusters", &info.has_clusters);
    c.insert("ws_has_workflows", &info.has_workflows);
}

async fn ws_path(app: &AppState, ws_name: &str) -> Option<PathBuf> {
    let detail = ctx::invoke_tool(app, "workspace_show", json!({ "name": ws_name })).await.ok()?;
    let p = detail["path"].as_str()?;
    Some(PathBuf::from(p))
}

// ── Auth / Sessions ───────────────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct LoginForm {
    pub password: String,
}

pub async fn login_page(State(app): State<AppState>, headers: HeaderMap) -> Response {
    if let Some(id) = extract_session_cookie(&headers) {
        if app.sessions.get(id).await.is_some() {
            return Redirect::to("/ui/").into_response();
        }
    }
    let mut c = base_ctx(&app);
    c.insert("error", &Option::<String>::None);
    match render(&app, "pages/login.html", &c) {
        Ok(html) => html.into_response(),
        Err(e) => e.into_response(),
    }
}

pub async fn login_post(
    State(app): State<AppState>,
    Form(form): Form<LoginForm>,
) -> Response {
    let authenticated = validate_credentials(&app, &form.password).await;

    if !authenticated {
        let mut c = base_ctx(&app);
        c.insert("error", &Some("Invalid credentials"));
        return match render(&app, "pages/login.html", &c) {
            Ok(html) => (StatusCode::UNAUTHORIZED, html).into_response(),
            Err(e) => e.into_response(),
        };
    }

    let session = SessionData::new("ui-user", vec!["admin".to_string()]);
    let session_id = app.sessions.create(session).await;
    app.sessions.prune().await;

    let cookie = format!(
        "{}={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=28800",
        super::session::COOKIE_NAME,
        session_id
    );
    let mut resp_headers = HeaderMap::new();
    resp_headers.insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
    (resp_headers, Redirect::to("/ui/")).into_response()
}

async fn validate_credentials(app: &AppState, password: &str) -> bool {
    if password.is_empty() {
        return false;
    }

    // 1. Control-center delegation (primary when configured)
    if let Some(ref cc_url) = app.control_center_url {
        if let Ok(client) = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(5))
            .build()
        {
            let url = format!("{}/api/v1/auth/verify", cc_url);
            match client.post(&url).bearer_auth(password).send().await {
                Ok(resp) if resp.status().is_success() => return true,
                Ok(_) => return false,
                Err(e) => tracing::warn!("control-center auth request failed: {e}"),
            }
        }
    }

    // 2. Local JWT validation
    if let Some(ref jwt) = app.jwt {
        if jwt.validate(password).is_ok() {
            return true;
        }
    }

    // 3. Static UI password
    if let Some(ref ui_pw) = app.ui_password {
        return password == ui_pw.as_ref();
    }

    // 4. Open/solo mode — any non-empty password when no auth configured
    app.jwt.is_none() && app.ui_password.is_none() && app.control_center_url.is_none()
}

pub async fn logout(State(app): State<AppState>, headers: HeaderMap) -> Response {
    if let Some(id) = extract_session_cookie(&headers) {
        app.sessions.delete(id).await;
    }
    let expire = format!(
        "{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
        super::session::COOKIE_NAME
    );
    let mut resp_headers = HeaderMap::new();
    resp_headers.insert(header::SET_COOKIE, HeaderValue::from_str(&expire).unwrap());
    (resp_headers, Redirect::to("/ui/login")).into_response()
}

// ── Dashboard ─────────────────────────────────────────────────────────────────

pub async fn dashboard(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
) -> Result<Html<String>, UiError> {
    let stats = ctx::invoke_tool(&app, "dashboard_statistics", json!({})).await.ok();
    let audit = ctx::invoke_tool(&app, "dashboard_audit",      json!({"limit": 20})).await.ok();
    let tool_count = app.registry.list().len();
    let total_invocations = stats.as_ref()
        .and_then(|s| s["total_invocations"].as_u64())
        .unwrap_or(0);
    let active_sessions = stats.as_ref()
        .and_then(|s| s["active_sessions"].as_u64())
        .unwrap_or(0);
    let secs_since_reload: Option<u64> = stats.as_ref()
        .and_then(|s| s["secs_since_reload"].as_u64());
    // audit tool may return an array directly or a wrapper object
    let recent: &Value = audit.as_ref().and_then(|a| {
        if a.is_array() { Some(a) } else { a.get("items") }
    }).unwrap_or(&Value::Null);
    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("tool_count",         &tool_count);
    c.insert("total_invocations",  &total_invocations);
    c.insert("active_sessions",    &active_sessions);
    c.insert("secs_since_reload",  &secs_since_reload);
    c.insert("recent",             recent);
    render(&app, "pages/dashboard.html", &c)
}

// ── Tools ─────────────────────────────────────────────────────────────────────

pub async fn tools_list(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
) -> Result<Html<String>, UiError> {
    let tools = app.registry.list();
    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("tools", &tools);
    render(&app, "pages/tools.html", &c)
}

pub async fn tool_detail(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Response, UiError> {
    let Some(meta) = app.registry.metadata(&name) else {
        return Ok(Redirect::to("/ui/tools").into_response());
    };
    let schema_pretty = serde_json::to_string_pretty(&*meta.schema).unwrap_or_default();
    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("tool", meta);
    c.insert("schema_pretty", &schema_pretty);
    Ok(render(&app, "pages/tool_detail.html", &c)?.into_response())
}

// ── Workspaces ────────────────────────────────────────────────────────────────

pub async fn workspaces(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
) -> Result<Html<String>, UiError> {
    let raw = ctx::invoke_tool(&app, "workspace_list", json!({})).await.ok();
    let ws_root_set = !app.env.workspaces_root.as_os_str().is_empty();
    let import_path = app.nickel_import_path.as_deref();

    // Async gather: nickel export card.ncl for each infra workspace.
    let raw_pairs: Vec<(Value, std::path::PathBuf)> = raw
        .as_ref()
        .and_then(|v| v["items"].as_array())
        .map(|items| items.iter().filter_map(|ws| {
            let path = ws["path"].as_str().unwrap_or("");
            if path.is_empty() { return None; }
            let card_path = std::path::Path::new(path).join("card.ncl");
            if !card_path.exists() { return None; }
            Some((ws.clone(), card_path))
        }).collect())
        .unwrap_or_default();

    let mut enriched: Vec<Value> = Vec::with_capacity(raw_pairs.len());
    for (ws, card_path) in raw_pairs {
        let card = ctx::ncl_export(&card_path, import_path).await;
        let tagline = card["tagline"].as_str().unwrap_or("").to_string();
        let status  = card["status"].as_str().unwrap_or("").to_string();
        let tags: Vec<String> = card["tags"].as_array()
            .map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_owned)).collect())
            .unwrap_or_default();
        let features: Vec<String> = card["features"].as_array()
            .map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_owned)).collect())
            .unwrap_or_default();
        enriched.push(json!({
            "name":       ws["name"],
            "path":       ws["path"],
            "active":     ws["active"],
            "environments": ws["environments"],
            "tagline":    tagline,
            "status":     status,
            "tags":       tags,
            "features":   features,
        }));
    }

    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("infra_workspaces", &enriched);
    c.insert("ws_root_set", &ws_root_set);
    render(&app, "pages/workspaces.html", &c)
}

pub async fn workspace_detail(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Response, UiError> {
    let detail = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await;
    match detail {
        Err(_) => Ok(Redirect::to("/ui/workspaces").into_response()),
        Ok(detail) => {
            let wi = ws_env_info(Some(&detail));
            let ws_path = detail["path"].as_str().unwrap_or("");
            let import_path = app.nickel_import_path.as_deref();

            // Build enriched infra-only environment list with component + server states.
            let infra_env_set: std::collections::HashSet<&str> =
                wi.envs.iter().map(String::as_str).collect();

            let mut infra_envs: Vec<Value> = Vec::new();
            let mut total_comp_count: usize = 0;
            let mut total_wf_count: usize = 0;

            if let Some(envs) = detail["environments"].as_array() {
                // Collect all server names across infra envs for one hcloud call.
                let ncl_server_names: Vec<String> = envs.iter()
                    .filter(|e| infra_env_set.contains(e["name"].as_str().unwrap_or("")))
                    .flat_map(|_e| {
                        // We don't know server names yet — gather from state files below.
                        // Placeholder: hcloud_server_statuses called after state load.
                        std::iter::empty::<String>()
                    })
                    .collect();
                let _ = ncl_server_names; // resolved per-env below

                for env_val in envs {
                    let env_name = env_val["name"].as_str().unwrap_or("");
                    if !infra_env_set.contains(env_name) { continue; }

                    let states = load_env_states(ws_path, env_name, import_path).await;

                    // Fetch live hcloud status for the servers known in the state file.
                    let srv_names: Vec<String> = states.servers.keys().cloned().collect();
                    let hcloud = hcloud_server_statuses(&app, &srv_names).await;

                    // Components: taskserv → NCL state; cluster → hcloud-inferred.
                    // We don't have mode info at this level, so mark "cluster" if not in state.
                    let all_running = !hcloud.is_empty()
                        && hcloud.values().all(|s| s == "running");

                    let components: Vec<Value> = build_overview_components(
                        env_val["components"].as_array(),
                        &states.components,
                        all_running,
                    );

                    // Servers: prefer live hcloud status over NCL aggregate.
                    let mut servers: Vec<Value> = build_overview_servers(&states.servers, &hcloud);
                    servers.sort_by(|a, b| {
                        a["name"].as_str().unwrap_or("").cmp(b["name"].as_str().unwrap_or(""))
                    });

                    let workflows: Vec<&Value> = env_val["workflows"]
                        .as_array()
                        .map(|a| a.iter().collect())
                        .unwrap_or_default();

                    total_comp_count += components.len();
                    total_wf_count += workflows.len();

                    infra_envs.push(json!({
                        "name":       env_name,
                        "components": components,
                        "servers":    servers,
                        "workflows":  workflows,
                    }));
                }
            }

            let total_srv_count: usize = infra_envs.iter()
                .filter_map(|e| e["servers"].as_array())
                .map(|a| a.len())
                .sum();

            let card_path = std::path::Path::new(ws_path).join("card.ncl");
            let ws_card = if card_path.exists() {
                ctx::ncl_export(&card_path, import_path).await
            } else {
                Value::Null
            };

            let mut c = ws_ctx(&app, &name, "overview");
            perm_ctx(&perms, &mut c);
            insert_ws_env_info(&mut c, &wi);
            c.insert("ws",         &detail);
            c.insert("ws_card",    &ws_card);
            c.insert("infra_envs", &infra_envs);
            c.insert("comp_count", &total_comp_count);
            c.insert("wf_count",   &total_wf_count);
            c.insert("srv_count",  &total_srv_count);
            Ok(render(&app, "pages/workspace_detail.html", &c)?.into_response())
        }
    }
}

pub async fn workspace_servers(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
    Query(params): Query<HashMap<String, String>>,
) -> Result<Response, UiError> {
    let env_filter = params.get("env").cloned();
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());

    // Load role/scale from servers.ncl for the active env.
    let ws_path = ws.as_ref().and_then(|v| v["path"].as_str()).unwrap_or("");
    let import_path = app.nickel_import_path.as_deref();
    let ncl_env = env_filter.as_deref()
        .or_else(|| wi.envs.first().map(String::as_str))
        .unwrap_or("");
    let roles = load_infra_server_roles(ws_path, ncl_env, import_path).await;

    let cache = load_servers_cache(ws_path, ncl_env).await;
    let dag_states = load_provisioning_taskservs(ws_path, ncl_env).await;

    // Build hcloud lookup: name → full server JSON (non-fatal if unavailable).
    let hcloud_map: HashMap<String, Value> = ctx::invoke_tool(&app, "server_list", json!({}))
        .await
        .ok()
        .and_then(|sv| sv["items"].as_array().cloned())
        .unwrap_or_default()
        .into_iter()
        .filter_map(|s| {
            let name = s["name"].as_str()?.to_string();
            Some((name, s))
        })
        .collect();

    let enrich = |mut s: Value, hostname: &str, meta: Option<&Value>| -> Value {
        s["role"]     = meta.map(|m| m["role"].clone()).unwrap_or(json!(""));
        s["scale"]    = meta.map(|m| m["scale"].clone()).unwrap_or(Value::Null);
        s["provider"] = json!("hetzner");
        let deploy = meta.and_then(|m| m["not_use"].as_bool()).map(|nu| if nu { "skip" } else { "yes" }).unwrap_or("yes");
        s["deploy"] = json!(deploy);
        if let Some(cached) = cache.get(hostname) {
            s["floating_ip"]         = cached.get("floating_ip").cloned().unwrap_or(Value::Null);
            s["floating_ip_address"] = cached.get("floating_ip_address").cloned().unwrap_or(Value::Null);
            s["last_sync"]           = cached.get("last_sync").cloned().unwrap_or(Value::Null);
        }
        let server_dag = dag_states.get(hostname).and_then(|v| v.as_array()).cloned().unwrap_or_default();
        let total_tasks = server_dag.len();
        let completed   = server_dag.iter().filter(|t| t["state"].as_str() == Some("completed")).count();
        let failed      = server_dag.iter().filter(|t| t["state"].as_str() == Some("failed")).count();
        s["fsm_state"] = if total_tasks == 0 {
            json!("—")
        } else if failed > 0 {
            json!(format!("{completed}/{total_tasks} [{failed}✗]"))
        } else {
            json!(format!("{completed}/{total_tasks}"))
        };
        s
    };

    // Primary source: NCL-declared servers. Enrich with hcloud data where available.
    // This ensures servers are always visible even when the provider API is unreachable.
    let items: Vec<Value> = if roles.is_empty() {
        // No NCL declarations — fall back to whatever hcloud returned.
        hcloud_map.values().cloned().map(|s| {
            let hostname = s["name"].as_str().unwrap_or("").to_string();
            enrich(s, &hostname, None)
        }).collect()
    } else {
        let mut out = Vec::with_capacity(roles.len());
        for (hostname, meta) in &roles {
            let s = if let Some(hcloud) = hcloud_map.get(hostname) {
                hcloud.clone()
            } else {
                json!({ "name": hostname, "status": "not_provisioned" })
            };
            out.push(enrich(s, hostname, Some(meta)));
        }
        out.sort_by(|a, b| {
            a["name"].as_str().unwrap_or("").cmp(b["name"].as_str().unwrap_or(""))
        });
        out
    };
    let total = items.len();
    let servers = if total > 0 || !roles.is_empty() {
        Some(json!({ "items": items, "total": total }))
    } else {
        None
    };

    let mut c = ws_ctx(&app, &name, "servers");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("ws", &ws);
    c.insert("servers", &servers);
    c.insert("current_env", &env_filter.unwrap_or_default());
    Ok(render(&app, "pages/workspace_servers.html", &c)?.into_response())
}

pub async fn workspace_server_detail(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path((ws_name, srv_name)): Path<(String, String)>,
) -> Result<Response, UiError> {
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let ws_path = ws.as_ref().and_then(|v| v["path"].as_str()).unwrap_or("");
    let import_path = app.nickel_import_path.as_deref();

    // Search role/scale across all infra envs — first match wins.
    let srv_meta = {
        let mut found = Value::Null;
        for env in &wi.envs {
            let roles = load_infra_server_roles(ws_path, env, import_path).await;
            if let Some(meta) = roles.get(&srv_name) {
                found = meta.clone();
                break;
            }
        }
        found
    };

    // Load cache + provisioning state from the first env that has server data.
    let srv_env = wi.envs.iter()
        .find(|env| {
            let p = std::path::Path::new(ws_path).join("infra").join(env).join("servers.ncl");
            p.exists()
        })
        .cloned()
        .unwrap_or_default();

    let cache = load_servers_cache(ws_path, &srv_env).await;
    let srv_cache = cache.get(&srv_name).cloned().unwrap_or(Value::Null);

    let all_taskservs = load_provisioning_taskservs(ws_path, &srv_env).await;
    let dag_taskservs = all_taskservs.get(&srv_name).cloned().unwrap_or(Value::Array(vec![]));
    let dag_total = dag_taskservs.as_array().map(|a| a.len()).unwrap_or(0);
    let dag_deployed = dag_taskservs.as_array().map(|a| {
        a.iter().filter(|ts| ts["state"].as_str() == Some("completed")).count()
    }).unwrap_or(0);

    let srv_volumes_declared = srv_meta.get("additional_volumes").cloned().unwrap_or(Value::Array(vec![]));

    let server = ctx::invoke_tool(&app, "server_show", json!({ "name": &srv_name })).await.ok();

    // Enrich hcloud-attached volumes: for each int ID in server.volumes, fetch full detail.
    let hcloud_volumes: Vec<Value> = if let Some(srv) = &server {
        let vol_ids: Vec<i64> = srv["volumes"]
            .as_array()
            .map(|a| a.iter().filter_map(|v| v.as_i64()).collect())
            .unwrap_or_default();
        let mut enriched = Vec::with_capacity(vol_ids.len());
        for vol_id in vol_ids {
            let detail = ctx::invoke_tool(&app, "volume_show", json!({ "name": vol_id.to_string() })).await;
            enriched.push(match detail {
                Ok(v) => v,
                Err(_) => json!({ "id": vol_id, "name": format!("{vol_id}"), "error": true }),
            });
        }
        enriched
    } else {
        vec![]
    };

    let mut c = ws_ctx(&app, &ws_name, "servers");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("server",               &server);
    c.insert("srv_name",             &srv_name);
    c.insert("srv_role",             &srv_meta["role"]);
    c.insert("srv_scale",            &srv_meta["scale"]);
    c.insert("srv_cache",            &srv_cache);
    c.insert("dag_taskservs",        &dag_taskservs);
    c.insert("dag_total",            &dag_total);
    c.insert("dag_deployed",         &dag_deployed);
    c.insert("srv_volumes_declared", &srv_volumes_declared);
    c.insert("hcloud_volumes",       &hcloud_volumes);
    Ok(render(&app, "pages/workspace_server_detail.html", &c)?.into_response())
}

/// Show detail for a single Hetzner Cloud volume within a workspace context.
pub async fn workspace_volume_detail(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path((ws_name, vol_id)): Path<(String, String)>,
) -> Result<Response, UiError> {
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let volume = ctx::invoke_tool(&app, "volume_show", json!({ "name": &vol_id })).await.ok();
    let mut c = ws_ctx(&app, &ws_name, "servers");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("volume", &volume);
    c.insert("vol_id", &vol_id);
    Ok(render(&app, "pages/workspace_volume_detail.html", &c)?.into_response())
}

/// POST `/ui/workspaces/{ws}/servers/{srv}/scale-down`
///
/// Gate-1: rejects ControlPlane nodes (HTTP 422).
/// Gate-2: rejects if removing this node would bring the live worker count below scale.min.
/// Only reaches server_delete on Worker / LoadBalancer nodes that pass both gates.
pub async fn server_scale_down(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path((ws_name, srv_name)): Path<(String, String)>,
) -> Result<Response, UiError> {
    if !perms.can_operate {
        return Ok((
            StatusCode::FORBIDDEN,
            Json(json!({ "error": "Requires operator permission" })),
        ).into_response());
    }

    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let ws_path = ws.as_ref().and_then(|v| v["path"].as_str()).unwrap_or("");
    let import_path = app.nickel_import_path.as_deref();

    // Resolve role + scale for this server across all infra envs.
    let mut srv_role  = String::new();
    let mut srv_scale = Value::Null;
    for env in &wi.envs {
        let roles = load_infra_server_roles(ws_path, env, import_path).await;
        if let Some(meta) = roles.get(&srv_name) {
            srv_role  = meta["role"].as_str().unwrap_or("").to_string();
            srv_scale = meta["scale"].clone();
            break;
        }
    }

    // Gate-1: ControlPlane nodes are immutable via the UI.
    if srv_role == "ControlPlane" {
        return Ok((
            StatusCode::UNPROCESSABLE_ENTITY,
            Json(json!({
                "error": "ControlPlane nodes cannot be deleted via scale-down",
                "role":  srv_role,
                "hint":  "Use the CLI teardown workflow to destroy the full infra env",
            })),
        ).into_response());
    }

    // Gate-2: enforce scale.min if a ScalePolicy is declared.
    if !srv_scale.is_null() {
        let min = srv_scale["min"].as_u64().unwrap_or(0) as usize;
        if min > 0 {
            let pattern = srv_scale["template"]["hostname_pattern"]
                .as_str()
                .unwrap_or("");
            let prefix = pattern.split('{').next().unwrap_or("");
            if !prefix.is_empty() {
                let all = ctx::invoke_tool(&app, "server_list", json!({})).await.ok();
                let is_peer = |s: &&Value| {
                    s["name"].as_str()
                        .map(|n| n != srv_name && n.starts_with(prefix))
                        .unwrap_or(false)
                };
                let live_count = all
                    .as_ref()
                    .and_then(|v| v["items"].as_array())
                    .map(|items| items.iter().filter(is_peer).count())
                    .unwrap_or(0);
                // live_count is the count *after* removal of this node.
                if live_count < min {
                    return Ok((
                        StatusCode::UNPROCESSABLE_ENTITY,
                        Json(json!({
                            "error": format!(
                                "Removing {} would leave {} worker(s), below scale.min ({})",
                                srv_name, live_count, min
                            ),
                            "live_after_removal": live_count,
                            "scale_min":          min,
                        })),
                    ).into_response());
                }
            }
        }
    }

    let result = ctx::invoke_tool(&app, "server_delete", json!({ "name": &srv_name })).await?;
    Ok(Json(result).into_response())
}

struct CpTarget {
    ip:   String,
    user: String,
    key:  String,
}

/// Find CP server's public IP + SSH credentials from the roles map + hcloud list.
fn find_cp_target(roles: &HashMap<String, Value>, hcloud_items: &[Value]) -> Option<CpTarget> {
    let (cp_name, cp_meta) = roles.iter()
        .find(|(_, v)| v["role"].as_str() == Some("ControlPlane"))?;
    let ip = hcloud_items.iter()
        .find(|s| s["name"].as_str() == Some(cp_name.as_str()))
        .and_then(|s| s["public_net"]["ipv4"]["ip"].as_str())
        .map(|s| s.to_string())?;
    let user = cp_meta["ssh_user"].as_str().unwrap_or("root").to_string();
    let key  = cp_meta["ssh_key"].as_str().unwrap_or("~/.ssh/htz_ops").to_string();
    Some(CpTarget { ip, user, key })
}

/// Parse `cluster_pods` response into a flat pod list + namespace→(running,total) index.
fn unpack_pods(resp: Option<&Value>) -> (Vec<Value>, HashMap<String, (usize, usize)>) {
    let Some(items) = resp.and_then(|v| v["items"].as_array()) else {
        return (Vec::new(), HashMap::new());
    };
    let mut ns_map: HashMap<String, (usize, usize)> = HashMap::new();
    for pod in items {
        let ns    = pod["metadata"]["namespace"].as_str().unwrap_or("").to_string();
        let phase = pod["status"]["phase"].as_str().unwrap_or("");
        let e     = ns_map.entry(ns).or_insert((0, 0));
        e.1 += 1;
        if phase == "Running" { e.0 += 1; }
    }
    (items.clone(), ns_map)
}

/// Count pods matching a name pattern (CLI-equivalent for components without a namespace).
fn count_pods_by_name(pods: &[Value], name_pattern: &str) -> (usize, usize) {
    let total   = pods.iter().filter(|p| p["metadata"]["name"].as_str().unwrap_or("").contains(name_pattern)).count();
    let running = pods.iter().filter(|p| {
        p["metadata"]["name"].as_str().unwrap_or("").contains(name_pattern)
            && p["status"]["phase"].as_str() == Some("Running")
    }).count();
    (running, total)
}

pub async fn workspace_components(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
    Query(params): Query<HashMap<String, String>>,
) -> Result<Response, UiError> {
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let env_filter = params.get("env").cloned();

    let ws_path = ws.as_ref()
        .and_then(|v| v["path"].as_str())
        .unwrap_or("");
    let import_path = app.nickel_import_path.as_deref();

    // Load component states from .provisioning-state.ncl across all infra envs.
    let filtered_envs: Vec<String> = match &env_filter {
        Some(e) => wi.envs.iter().filter(|env| env.as_str() == e.as_str()).cloned().collect(),
        None    => wi.envs.clone(),
    };
    // Load NCL states (taskserv components) and env-level server states for hcloud lookup.
    let mut comp_states: HashMap<String, String> = HashMap::new();
    let mut comp_server_map: HashMap<String, String> = HashMap::new();
    let mut all_srv_names: Vec<String> = Vec::new();
    for env in &filtered_envs {
        let s = load_env_states(ws_path, env, import_path).await;
        for (k, v) in s.components {
            let rank = state_rank(&v);
            let e = comp_states.entry(k).or_insert_with(|| v.clone());
            if rank > state_rank(e) { *e = v; }
        }
        for (k, v) in s.comp_server {
            comp_server_map.entry(k).or_insert(v);
        }
        for k in s.servers.into_keys() {
            if !all_srv_names.contains(&k) { all_srv_names.push(k); }
        }
    }
    // Fetch full hcloud server list once — needed for both status and public IP lookup.
    let raw_hcloud = ctx::invoke_tool(&app, "server_list", json!({})).await.ok();
    let all_hcloud_items: Vec<Value> = raw_hcloud
        .as_ref()
        .and_then(|v| v["items"].as_array().cloned())
        .unwrap_or_default();

    // name → status map (same logic as hcloud_server_statuses but reuses the fetched list)
    let hcloud: HashMap<String, String> = all_hcloud_items.iter()
        .filter_map(|s| {
            let n = s["name"].as_str()?.to_string();
            if !all_srv_names.is_empty() && !all_srv_names.contains(&n) { return None; }
            Some((n, s["status"].as_str().unwrap_or("unknown").to_string()))
        })
        .collect();
    let all_srvs_running = !hcloud.is_empty() && hcloud.values().all(|s| s == "running");

    // SSH to the ControlPlane(s) and collect all pods once.
    // all_pods: for name-based matching (taskserv without namespace, same logic as CLI).
    // ns_pods: namespace → (running, total) for namespace-declared components.
    let mut all_pods: Vec<Value> = Vec::new();
    let mut ns_pods: HashMap<String, (usize, usize)> = HashMap::new();
    for env in &filtered_envs {
        let roles  = load_infra_server_roles(ws_path, env, import_path).await;
        let cp_tgt = find_cp_target(&roles, &all_hcloud_items);
        if let Some(tgt) = cp_tgt {
            let key_expanded = shellexpand::tilde(&tgt.key).into_owned();
            let resp = ctx::invoke_tool(&app, "cluster_pods", json!({
                "host": tgt.ip, "user": tgt.user, "key": key_expanded,
            })).await.ok();
            let (items, ns_map) = unpack_pods(resp.as_ref());
            all_pods.extend(items);
            for (ns, (r, t)) in ns_map {
                let e = ns_pods.entry(ns).or_insert((0, 0));
                e.0 += r; e.1 += t;
            }
        }
    }

    let list_params = match &env_filter {
        Some(e) => json!({ "workspace": &name, "env": e }),
        None    => json!({ "workspace": &name }),
    };
    let raw_items = ctx::invoke_tool(&app, "component_list", list_params)
        .await.ok()
        .and_then(|v| v["items"].as_array().cloned())
        .unwrap_or_default();

    let components: Vec<Value> = raw_items.into_iter().map(|item| {
        let comp_name = item["name"].as_str().unwrap_or("").to_string();
        let inner = item["config"].get(&comp_name).cloned().unwrap_or(Value::Null);
        let mode = inner["mode"].as_str().unwrap_or("");
        let mut ops: Vec<String> = inner["operations"]
            .as_object()
            .map(|o| {
                o.iter()
                    .filter(|(_, v)| v.as_bool().unwrap_or(false))
                    .map(|(k, _)| k.clone())
                    .collect()
            })
            .unwrap_or_default();
        ops.sort();
        let ncl_state = comp_states.get(&comp_name).cloned().unwrap_or_default();
        // Pod count resolution — matches CLI `comp-live-check` logic:
        //   namespace set → namespace-based lookup in ns_pods
        //   no namespace  → name-based search across all_pods (underscore→hyphen)
        let ns = inner["namespace"].as_str().unwrap_or("");
        let (pod_running, pod_total) = if !ns.is_empty() {
            ns_pods.get(ns).copied().unwrap_or((0, 0))
        } else if !all_pods.is_empty() {
            count_pods_by_name(&all_pods, &comp_name.replace('_', "-"))
        } else {
            (0, 0)
        };
        let pod_detail = if pod_total > 0 { format!("{pod_running}/{pod_total} pods") } else { String::new() };
        let pod_badge = if pod_detail.is_empty() { ""
        } else if pod_running == pod_total { "badge-success" }
        else if pod_running == 0           { "badge-error" }
        else                               { "badge-warning" };
        // hcloud / cluster-server live status (taskserv fallback)
        let hcloud_status = comp_server_map.get(&comp_name)
            .and_then(|srv| hcloud.get(srv))
            .cloned()
            .unwrap_or_default();
        let live_state = if !hcloud_status.is_empty() { hcloud_status }
            else if all_srvs_running { "running".to_string() }
            else { String::new() };
        let state = if !ncl_state.is_empty() {
            ncl_state.clone()
        } else if !live_state.is_empty() {
            live_state.clone()
        } else {
            String::new()
        };
        json!({
            "name":       comp_name,
            "env":        item["env"],
            "mode":       mode,
            "target":     inner["target"].as_str().unwrap_or(""),
            "namespace":  inner["namespace"].as_str().unwrap_or(""),
            "version":    inner["version"].as_str().unwrap_or(""),
            "operations": ops,
            "ncl_state":  ncl_state,
            "live_state": live_state,
            "pod_detail": pod_detail,
            "pod_badge":  pod_badge,
            "state":      state,
        })
    }).collect();

    let mut c = ws_ctx(&app, &name, "components");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("components", &components);
    c.insert("current_env", &env_filter.unwrap_or_default());
    Ok(render(&app, "pages/workspace_components.html", &c)?.into_response())
}

pub async fn workspace_component_detail(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path((ws_name, comp_name)): Path<(String, String)>,
) -> Result<Response, UiError> {
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let env_name = wi.envs.first().cloned().unwrap_or_default();

    let raw = if !env_name.is_empty() {
        ctx::invoke_tool(&app, "component_show", json!({
            "workspace": &ws_name, "env": &env_name, "name": &comp_name,
        })).await.ok()
    } else {
        None
    };

    let component = raw.as_ref().map(|r| {
        let inner = r["config"].get(&comp_name).cloned().unwrap_or(Value::Null);
        let mut ops: Vec<String> = inner["operations"]
            .as_object()
            .map(|o| {
                let mut v: Vec<String> = o.iter()
                    .filter(|(_, val)| val.as_bool().unwrap_or(false))
                    .map(|(k, _)| k.clone())
                    .collect();
                v.sort();
                v
            })
            .unwrap_or_default();
        ops.sort();
        let ext = &r["extension_metadata"];
        let ext_provides: Vec<String> = ext["provides"].as_array()
            .map(|arr| arr.iter().filter_map(|o| {
                o["id"].as_str().map(str::to_owned)
                    .or_else(|| o.as_str().map(str::to_owned))
            }).collect())
            .unwrap_or_default();
        let ext_requires: Vec<String> = ext["requires"].as_array()
            .map(|arr| arr.iter().filter_map(|o| {
                o["capability"].as_str().map(str::to_owned)
                    .or_else(|| o.as_str().map(str::to_owned))
            }).collect())
            .unwrap_or_default();
        let ext_conflicts: Vec<String> = ext["conflicts_with"].as_array()
            .map(|arr| arr.iter().filter_map(|o| {
                o.as_str().map(str::to_owned)
                    .or_else(|| o["id"].as_str().map(str::to_owned))
            }).collect())
            .unwrap_or_default();
        json!({
            "name":           &comp_name,
            "env":            &env_name,
            "workspace":      &ws_name,
            "mode":           inner["mode"].as_str().unwrap_or(""),
            "target":         inner["target"].as_str().unwrap_or(""),
            "namespace":      inner["namespace"].as_str().unwrap_or(""),
            "version":        inner["version"].as_str().unwrap_or(""),
            "operations":     ops,
            "ext_description": ext["description"].as_str().unwrap_or(""),
            "ext_provides":   ext_provides,
            "ext_requires":   ext_requires,
            "ext_conflicts":  ext_conflicts,
        })
    });

    let dag_entries: Vec<Value> = ctx::invoke_tool(&app, "workspace_dag", json!({ "name": &ws_name }))
        .await.ok()
        .and_then(|d| d["items"].as_array().cloned())
        .unwrap_or_default()
        .into_iter()
        .flat_map(|item| {
            let formula_name = item["name"].as_str().unwrap_or("").to_string();
            item["dag"]["formulas"]
                .as_array()
                .cloned()
                .unwrap_or_default()
                .into_iter()
                .filter(|f| {
                    f["taskserv"].as_str() == Some(&comp_name)
                        || f["component"].as_str() == Some(&comp_name)
                        || f["name"].as_str().map(|n| n.contains(&comp_name)).unwrap_or(false)
                })
                .map(move |f| json!({
                    "formula":  &formula_name,
                    "server":   f["server"].as_str().unwrap_or(""),
                    "state":    f["state"].as_str().unwrap_or("—"),
                    "started":  f["started_at"].as_str().unwrap_or(""),
                    "ended":    f["completed_at"].as_str().unwrap_or(""),
                }))
                .collect::<Vec<_>>()
        })
        .collect();

    let mut c = ws_ctx(&app, &ws_name, "components");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("component",   &component);
    c.insert("comp_name",   &comp_name);
    c.insert("env_name",    &env_name);
    c.insert("dag_entries", &dag_entries);
    Ok(render(&app, "pages/workspace_component_detail.html", &c)?.into_response())
}

/// Resolve CP target (IP + SSH user + SSH key) for a workspace (first env).
async fn resolve_ws_cp_target(app: &AppState, ws_name: &str) -> Option<CpTarget> {
    let ws = ctx::invoke_tool(app, "workspace_show", json!({ "name": ws_name })).await.ok()?;
    let ws_path = ws["path"].as_str()?;
    let envs = ws_infra_envs(&ws);
    let env = envs.first()?;
    let import_path = app.nickel_import_path.as_deref();
    let roles = load_infra_server_roles(ws_path, env, import_path).await;
    let raw = ctx::invoke_tool(app, "server_list", json!({})).await.ok()?;
    let items = raw["items"].as_array()?;
    find_cp_target(&roles, items)
}

/// Parse a Kubernetes pod JSON object into a compact display record.
fn parse_pod_entry(pod: &Value) -> Value {
    let name = pod["metadata"]["name"].as_str().unwrap_or("").to_string();
    let ns   = pod["metadata"]["namespace"].as_str().unwrap_or("").to_string();
    let phase = pod["status"]["phase"].as_str().unwrap_or("Unknown").to_string();
    let created = pod["metadata"]["creationTimestamp"].as_str().unwrap_or("").to_string();

    let containers = pod["status"]["containerStatuses"].as_array();
    let (ready, total, restarts) = containers.map(|arr| {
        let t = arr.len();
        let r = arr.iter().filter(|c| c["ready"].as_bool().unwrap_or(false)).count();
        let rs: i64 = arr.iter()
            .map(|c| c["restartCount"].as_i64().unwrap_or(0))
            .sum();
        (r, t, rs)
    }).unwrap_or((0, 0, 0));

    let ready_str = if total > 0 { format!("{ready}/{total}") } else { "—".into() };
    json!({ "name": name, "namespace": ns, "phase": phase, "ready": ready_str, "restarts": restarts, "created": created })
}

/// `GET /ui/workspaces/{ws}/components/{comp}/pods`
/// Returns live pod list for the component's configured namespace as JSON.
pub async fn workspace_component_pods(
    State(app): State<AppState>,
    Extension(_perms): Extension<UiPerms>,
    Path((ws_name, comp_name)): Path<(String, String)>,
) -> Result<Response, UiError> {
    let env_name = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &ws_name }))
        .await.ok()
        .and_then(|ws| ws_infra_envs(&ws).into_iter().next())
        .unwrap_or_default();

    let comp_ns = ctx::invoke_tool(&app, "component_show", json!({
        "workspace": &ws_name, "env": &env_name, "name": &comp_name,
    })).await.ok()
        .and_then(|r| {
            r["config"].get(&comp_name)
                .and_then(|c| c["namespace"].as_str())
                .map(str::to_owned)
        })
        .unwrap_or_default();

    let Some(tgt) = resolve_ws_cp_target(&app, &ws_name).await else {
        return Ok(Json(json!({ "error": "no control plane resolved", "items": [] })).into_response());
    };
    let key_expanded = shellexpand::tilde(&tgt.key).into_owned();

    let pods_resp = ctx::invoke_tool(&app, "cluster_pods", json!({
        "host": &tgt.ip, "user": &tgt.user, "key": &key_expanded,
    })).await;

    let name_pattern = comp_name.replace('_', "-");

    match pods_resp {
        Err(e) => {
            Ok(Json(json!({ "error": e.to_string(), "items": [] })).into_response())
        }
        Ok(pods_resp) => {
            let items: Vec<Value> = pods_resp["items"]
                .as_array()
                .map(|arr| {
                    arr.iter()
                        .filter(|p| {
                            if !comp_ns.is_empty() {
                                p["metadata"]["namespace"].as_str() == Some(&comp_ns)
                            } else {
                                p["metadata"]["name"].as_str().unwrap_or("").contains(name_pattern.as_str())
                            }
                        })
                        .map(parse_pod_entry)
                        .collect()
                })
                .unwrap_or_default();

            Ok(Json(json!({ "items": items, "namespace": comp_ns, "name_pattern": name_pattern })).into_response())
        }
    }
}

/// `GET /ui/workspaces/{ws}/components/{comp}/pods/{ns}/{pod}/describe`
/// Returns `kubectl describe pod` output as JSON `{ output: "..." }`.
pub async fn workspace_component_describe_pod(
    State(app): State<AppState>,
    Extension(_perms): Extension<UiPerms>,
    Path((ws_name, _comp_name, ns, pod)): Path<(String, String, String, String)>,
) -> Result<Response, UiError> {
    let Some(tgt) = resolve_ws_cp_target(&app, &ws_name).await else {
        return Ok(Json(json!({ "error": "no control plane resolved" })).into_response());
    };
    let key_expanded = shellexpand::tilde(&tgt.key).into_owned();

    let result = ctx::invoke_tool(&app, "cluster_describe_pod", json!({
        "host": &tgt.ip, "user": &tgt.user, "key": &key_expanded,
        "namespace": ns, "pod": pod,
    })).await
        .unwrap_or_else(|e| json!({ "error": e.to_string() }));

    Ok(Json(result).into_response())
}

pub async fn workspace_dag(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Response, UiError> {
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let dag_resp = ctx::invoke_tool(&app, "workspace_dag", json!({ "name": &name })).await.ok();

    let dag_json = dag_resp.as_ref()
        .and_then(|d| d["items"].as_array())
        .map(|items| dag_to_cytoscape(items))
        .and_then(|v| serde_json::to_string(&v).ok())
        .unwrap_or_else(|| r#"{"nodes":[],"edges":[]}"#.to_string());

    let env_name = dag_resp.as_ref()
        .and_then(|d| d["items"].as_array())
        .and_then(|items| items.first())
        .and_then(|item| item["env"].as_str())
        .unwrap_or("")
        .to_string();

    let wi = ws_env_info(ws.as_ref());
    let mut c = ws_ctx(&app, &name, "dag");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("ws", &ws);
    c.insert("dag_json", &dag_json);
    c.insert("env_name", &env_name);
    Ok(render(&app, "pages/workspace_dag.html", &c)?.into_response())
}

fn push_dep_edges(
    deps: &[Value],
    formula_id: &str,
    full_id: &str,
    edges: &mut Vec<Value>,
    edge_idx: &mut usize,
) {
    for dep in deps {
        let dep_id = dep["node_id"].as_str().unwrap_or("?");
        let dep_full = format!("{formula_id}::{dep_id}");
        let cond = dep["condition"].as_str().unwrap_or("");
        edges.push(json!({
            "id":     format!("e{edge_idx}"),
            "source": dep_full,
            "target": full_id,
            "kind":   cond,
        }));
        *edge_idx += 1;
    }
}

fn dag_to_cytoscape(items: &[Value]) -> Value {
    let mut nodes: Vec<Value> = Vec::new();
    let mut edges: Vec<Value> = Vec::new();
    let mut edge_idx = 0usize;

    for item in items {
        let formulas = match item["dag"]["formulas"].as_array() {
            Some(f) => f,
            None => continue,
        };
        for formula in formulas {
            let formula_id = formula["id"].as_str().unwrap_or("formula");
            let server = formula["server"].as_str().unwrap_or("");
            let formula_label = if server.is_empty() { formula_id } else { server };

            nodes.push(json!({
                "id":          formula_id,
                "label":       formula_label,
                "kind":        "formula",
                "description": formula["description"].as_str().unwrap_or(""),
                "server":      server,
            }));

            let task_nodes = formula["nodes"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
            for node in task_nodes {
                let node_id = node["id"].as_str().unwrap_or("?");
                let full_id = format!("{formula_id}::{node_id}");
                let taskserv_name = node["taskserv"]["name"].as_str().unwrap_or(node_id);
                let health_gate = node["health_gate"].as_bool().unwrap_or(false);

                nodes.push(json!({
                    "id":          &full_id,
                    "label":       taskserv_name,
                    "kind":        "task",
                    "description": format!("{node_id} on {server}"),
                    "health_gate": health_gate,
                }));

                let deps = node["depends_on"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
                if deps.is_empty() {
                    edges.push(json!({
                        "id":     format!("e{edge_idx}"),
                        "source": formula_id,
                        "target": &full_id,
                        "kind":   "entry",
                    }));
                    edge_idx += 1;
                } else {
                    push_dep_edges(deps, formula_id, &full_id, &mut edges, &mut edge_idx);
                }
            }
        }
    }

    json!({ "nodes": nodes, "edges": edges })
}

pub async fn workspace_clusters(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Response, UiError> {
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let clusters = ctx::invoke_tool(&app, "cluster_list", json!({})).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let mut c = ws_ctx(&app, &name, "clusters");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("ws", &ws);
    c.insert("clusters", &clusters);
    Ok(render(&app, "pages/workspace_clusters.html", &c)?.into_response())
}

// ── Workspace-scoped Reflect ──────────────────────────────────────────────────

pub async fn workspace_modes(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Html<String>, UiError> {
    let root = ws_path(&app, &name).await;
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let mode_list = root.as_deref().map(ctx::list_modes).unwrap_or_default();
    let mut c = ws_ctx(&app, &name, "modes");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("modes", &mode_list);
    c.insert("has_root", &root.is_some());
    render(&app, "pages/workspace_modes.html", &c)
}

pub async fn workspace_backlog(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Html<String>, UiError> {
    let root = ws_path(&app, &name).await;
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let import_path = app.nickel_import_path.as_deref();
    let backlog = match root.as_deref() {
        Some(p) => ctx::list_backlog(&p.join("reflection").join("backlog.ncl"), import_path).await,
        None    => json!({"items": []}),
    };
    let mut c = ws_ctx(&app, &name, "backlog");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("backlog", &backlog);
    c.insert("has_root", &root.is_some());
    render(&app, "pages/workspace_backlog.html", &c)
}

/// Form payload for adding a backlog item.
#[derive(Deserialize)]
pub struct BacklogAddForm {
    pub title:    String,
    pub kind:     String,
    pub priority: String,
    pub status:   String,
    pub notes:    String,
}

pub async fn workspace_backlog_add(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
    Form(form): Form<BacklogAddForm>,
) -> Result<Response, UiError> {
    if !perms.can_operate {
        return Ok(StatusCode::FORBIDDEN.into_response());
    }
    let Some(root) = ws_path(&app, &name).await else {
        return Ok(StatusCode::NOT_FOUND.into_response());
    };
    let backlog_path = root.join("reflection").join("backlog.ncl");

    // Derive next item ID from existing content length.
    let existing = std::fs::read_to_string(&backlog_path).unwrap_or_default();
    let item_count = existing.matches("make_item").count();
    let new_id = format!("bl-{:03}", item_count + 1);

    // Sanitize: no backslash or backtick in NCL strings.
    let sanitize = |s: &str| s.replace(['\\', '`'], "").replace('"', "'");
    let title = sanitize(&form.title);
    let notes = sanitize(&form.notes);

    // Only accept known enum variants.
    let kind = match form.kind.as_str() {
        "Bug" | "Todo" | "Feature" | "Ops" | "Refactor" => form.kind.clone(),
        _ => "Todo".to_string(),
    };
    let priority = match form.priority.as_str() {
        "High" | "Medium" | "Low" => form.priority.clone(),
        _ => "Medium".to_string(),
    };
    let status = match form.status.as_str() {
        "Open" | "Done" | "InProgress" => form.status.clone(),
        _ => "Open".to_string(),
    };

    let new_item = format!(
        "    s.make_item {{\n      id       = \"{new_id}\",\n      title    = \"{title}\",\n      kind     = '{kind},\n      priority = '{priority},\n      status   = '{status},\n      notes    = \"{notes}\",\n    }},\n"
    );

    // Insert before the closing `  ]` of the items array.
    let updated = if let Some(pos) = existing.rfind("  ],") {
        format!("{}{}{}", &existing[..pos], new_item, &existing[pos..])
    } else if let Some(pos) = existing.rfind("  ]") {
        format!("{}{}{}", &existing[..pos], new_item, &existing[pos..])
    } else {
        // File missing or malformed — create minimal skeleton.
        format!(
            "let s = import \"reflection/schemas/backlog.ncl\" in\n\n{{\n  items = [\n{new_item}  ],\n}}\n"
        )
    };

    std::fs::write(&backlog_path, updated)
        .map_err(|e| UiError::Tool(format!("write backlog.ncl: {e}")))?;

    Ok(Redirect::to(&format!("/ui/workspaces/{name}/backlog")).into_response())
}

pub async fn workspace_ontology(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Html<String>, UiError> {
    let root = ws_path(&app, &name).await;
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let import_path = app.nickel_import_path.as_deref();
    let graph_json = match root.as_deref() {
        Some(p) => {
            let v = ctx::ncl_export(&p.join(".ontology").join("core.ncl"), import_path).await;
            serde_json::to_string(&v).unwrap_or_else(|_| r#"{"nodes":[],"edges":[]}"#.to_string())
        }
        None => r#"{"nodes":[],"edges":[]}"#.to_string(),
    };
    let state_json = match root.as_deref() {
        Some(p) => {
            let v = ctx::ncl_export(&p.join(".ontology").join("state.ncl"), import_path).await;
            serde_json::to_string(&v).unwrap_or_else(|_| r#"{"dimensions":[]}"#.to_string())
        }
        None => r#"{"dimensions":[]}"#.to_string(),
    };
    let mut c = ws_ctx(&app, &name, "ontology");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("graph_json", &graph_json);
    c.insert("state_json", &state_json);
    c.insert("has_root", &root.is_some());
    render(&app, "pages/workspace_ontology.html", &c)
}

pub async fn workspace_actions(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Html<String>, UiError> {
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let all_jobs = ctx::invoke_tool(&app, "orchestrator_jobs", json!({"limit": 100}))
        .await.ok()
        .and_then(|v| v["items"].as_array().cloned())
        .unwrap_or_default();
    let jobs: Vec<Value> = all_jobs.into_iter()
        .filter(|j| j["name"].as_str()
            .map(|n| n.to_lowercase().contains(&name.to_lowercase()))
            .unwrap_or(false))
        .collect();
    let mut c = ws_ctx(&app, &name, "actions");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("jobs", &jobs);
    render(&app, "pages/workspace_actions.html", &c)
}

pub async fn workspace_notifications(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
) -> Result<Html<String>, UiError> {
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let audit = ctx::invoke_tool(&app, "dashboard_audit", json!({"limit": 50})).await.ok();
    let mut c = ws_ctx(&app, &name, "notifications");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("audit", &audit);
    render(&app, "pages/workspace_notifications.html", &c)
}

pub async fn workspace_config(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
    Query(params): Query<HashMap<String, String>>,
) -> Result<Html<String>, UiError> {
    let root = ws_path(&app, &name).await;
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let env_filter = params.get("env").cloned();
    let target_envs: Vec<String> = match &env_filter {
        Some(e) if wi.envs.contains(e) => vec![e.clone()],
        _ => wi.envs.clone(),
    };
    let import_path = app.nickel_import_path.as_deref();
    let env_configs: Vec<Value> = if let Some(ref p) = root {
        let mut out = Vec::new();
        for env in &target_envs {
            let settings = p.join("infra").join(env).join("settings.ncl");
            let config = ctx::ncl_export(&settings, import_path).await;
            let config_pretty = serde_json::to_string_pretty(&config).unwrap_or_default();
            out.push(json!({ "env": env, "config": config, "config_pretty": config_pretty }));
        }
        out
    } else {
        vec![]
    };
    let mut c = ws_ctx(&app, &name, "config");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("env_configs", &env_configs);
    c.insert("has_root", &root.is_some());
    c.insert("current_env", &env_filter.unwrap_or_default());
    render(&app, "pages/workspace_config.html", &c)
}

fn wf_steps_from_ncl(exported: &Value, wf_name: &str) -> (Vec<Value>, usize) {
    let Some(obj) = exported.as_object() else {
        return (vec![], 0);
    };
    let steps: Vec<Value> = obj.values().flat_map(|wf| {
        wf_steps_for_entry(wf, wf_name)
    }).collect();
    (steps, obj.len())
}

fn wf_steps_for_entry(wf: &Value, wf_name: &str) -> Vec<Value> {
    let wf_id = wf["id"].as_str().unwrap_or(wf_name).to_string();
    let wf_desc = wf["description"].as_str().unwrap_or("").to_string();
    let Some(steps) = wf["steps"].as_array() else { return vec![] };
    steps.iter().map(|s| {
        let targets: Vec<String> = s["targets"].as_array()
            .map(|ts| ts.iter().filter_map(|t| {
                let comp = t["component"].as_str().unwrap_or("");
                let op   = t["operation"].as_str().unwrap_or("");
                if comp.is_empty() { None } else { Some(format!("{comp}:{op}")) }
            }).collect())
            .unwrap_or_default();
        let deps: Vec<String> = s["depends_on"].as_array()
            .map(|d| d.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
            .unwrap_or_default();
        json!({
            "workflow_id":   wf_id,
            "workflow_desc": wf_desc,
            "id":        s["id"].as_str().unwrap_or(""),
            "targets":   targets,
            "depends_on": deps,
            "condition": s["condition"].as_str().unwrap_or(""),
            "on_error":  s["on_error"].as_str().unwrap_or(""),
        })
    }).collect()
}

pub async fn workspace_workflows(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(name): Path<String>,
    Query(params): Query<HashMap<String, String>>,
) -> Result<Html<String>, UiError> {
    let root = ws_path(&app, &name).await;
    let ws = ctx::invoke_tool(&app, "workspace_show", json!({ "name": &name })).await.ok();
    let wi = ws_env_info(ws.as_ref());
    let env_filter = params.get("env").cloned();
    let target_envs: Vec<String> = match &env_filter {
        Some(e) if wi.envs.contains(e) => vec![e.clone()],
        _ => wi.envs.clone(),
    };
    let import_path = app.nickel_import_path.as_deref();
    let env_workflows: Vec<Value> = if let Some(ref p) = root {
        let mut out = Vec::new();
        for env in &target_envs {
            let wf_dir = p.join("infra").join(env).join("workflows");
            if !wf_dir.is_dir() { continue; }
            let entries = std::fs::read_dir(&wf_dir)
                .into_iter()
                .flatten()
                .filter_map(|e| e.ok())
                .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("ncl"))
                .collect::<Vec<_>>();
            let mut workflows = Vec::new();
            for entry in entries {
                let path = entry.path();
                let wf_name = path.file_stem()
                    .and_then(|s| s.to_str())
                    .unwrap_or("")
                    .to_string();
                let exported = ctx::ncl_export(&path, import_path).await;
                // NCL exports as {workflow_id: {id, description, steps, ...}}
                let (steps_flat, wf_count) = wf_steps_from_ncl(&exported, &wf_name);
                workflows.push(json!({
                    "name": wf_name,
                    "count": wf_count,
                    "steps": steps_flat,
                }));
            }
            if !workflows.is_empty() {
                out.push(json!({ "env": env, "workflows": workflows }));
            }
        }
        out
    } else {
        vec![]
    };
    let mut c = ws_ctx(&app, &name, "workflows");
    perm_ctx(&perms, &mut c);
    insert_ws_env_info(&mut c, &wi);
    c.insert("env_workflows", &env_workflows);
    c.insert("has_root", &root.is_some());
    render(&app, "pages/workspace_workflows.html", &c)
}

// ── Jobs ──────────────────────────────────────────────────────────────────────

pub async fn jobs(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
) -> Result<Html<String>, UiError> {
    let jobs = ctx::invoke_tool(&app, "orchestrator_jobs", json!({"limit": 50})).await.ok();
    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("jobs", &jobs);
    render(&app, "pages/jobs.html", &c)
}

pub async fn job_detail(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
    Path(id): Path<String>,
) -> Result<Html<String>, UiError> {
    let job = ctx::invoke_tool(&app, "orchestrator_job_show", json!({ "id": &id })).await.ok();
    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("job", &job);
    c.insert("job_id", &id);
    render(&app, "pages/job_detail.html", &c)
}

// ── Extensions ────────────────────────────────────────────────────────────────

pub async fn extensions(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
) -> Result<Html<String>, UiError> {
    let ext_list = ctx::invoke_tool(&app, "extension_list", json!({})).await.ok();
    let capabilities = ctx::invoke_tool(&app, "extension_capabilities", json!({})).await.ok();
    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("extensions", &ext_list);
    c.insert("capabilities", &capabilities);
    render(&app, "pages/extensions.html", &c)
}

// ── Modes (global) ────────────────────────────────────────────────────────────

pub async fn modes(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
) -> Result<Html<String>, UiError> {
    let mode_list = match app.project_root.as_ref() {
        Some(root) => ctx::list_modes(root),
        None => vec![],
    };
    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("modes", &mode_list);
    c.insert("has_root", &app.project_root.is_some());
    render(&app, "pages/modes.html", &c)
}

#[derive(Deserialize)]
pub struct ModeRunForm {
    pub mode_id: String,
}

pub async fn mode_run(
    State(app): State<AppState>,
    Form(form): Form<ModeRunForm>,
) -> impl IntoResponse {
    let Some(root) = app.project_root.as_ref() else {
        return Redirect::to("/ui/modes").into_response();
    };
    let bin = app.provisioning_bin.as_deref().unwrap_or("provisioning");
    if let Err(e) = ctx::run_mode(root, &form.mode_id, bin).await {
        tracing::warn!(mode = %form.mode_id, error = %e, "mode run failed");
    }
    Redirect::to("/ui/modes").into_response()
}

// ── Ontology graph (global) ───────────────────────────────────────────────────

pub async fn ontology(
    State(app): State<AppState>,
    Extension(perms): Extension<UiPerms>,
) -> Result<Html<String>, UiError> {
    let graph_json = match app.project_root.as_ref() {
        Some(root) => {
            let core = root.join(".ontology").join("core.ncl");
            let import_path = app.nickel_import_path.as_deref();
            let v = ctx::ncl_export(&core, import_path).await;
            serde_json::to_string(&v).unwrap_or_else(|_| "{}".to_string())
        }
        None => r#"{"nodes":[],"edges":[]}"#.to_string(),
    };
    let state_json = match app.project_root.as_ref() {
        Some(root) => {
            let state_file = root.join(".ontology").join("state.ncl");
            let import_path = app.nickel_import_path.as_deref();
            let v = ctx::ncl_export(&state_file, import_path).await;
            serde_json::to_string(&v).unwrap_or_else(|_| "{}".to_string())
        }
        None => r#"{"dimensions":[]}"#.to_string(),
    };
    let mut c = base_ctx(&app);
    perm_ctx(&perms, &mut c);
    c.insert("graph_json", &graph_json);
    c.insert("state_json", &state_json);
    c.insert("has_root", &app.project_root.is_some());
    render(&app, "pages/ontology.html", &c)
}
