use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use axum::extract::{Form, Path, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use tera::Context;
use tokio::sync::RwLock;
use tracing::warn;

use super::auth::AuthUser;
use crate::api::AppState;

// ── Error ────────────────────────────────────────────────────────────────────

#[derive(Debug, thiserror::Error)]
pub enum UiError {
    #[error("template render error: {0}")]
    Render(#[from] tera::Error),
    #[error("ncl export failed for {path}: {reason}")]
    NclExport { path: String, reason: String },
    #[error("ui is not configured — start the daemon with --templates-dir")]
    NotConfigured,
    #[error("bad request: {0}")]
    BadRequest(String),
    #[error("forbidden: {0}")]
    Forbidden(String),
}

impl IntoResponse for UiError {
    fn into_response(self) -> Response {
        let status = match &self {
            UiError::BadRequest(_) => StatusCode::BAD_REQUEST,
            UiError::Forbidden(_) => StatusCode::FORBIDDEN,
            _ => StatusCode::INTERNAL_SERVER_ERROR,
        };
        let detail = if let UiError::Render(ref e) = self {
            use std::error::Error;
            let mut chain = format!("{e}");
            let mut src = e.source();
            while let Some(s) = src {
                chain.push_str(&format!("\nCaused by: {s}"));
                src = s.source();
            }
            chain
        } else {
            format!("{self}")
        };
        let html = format!(
            r#"<!DOCTYPE html><html data-theme="dark"><body class="p-8">
<h1 class="text-2xl font-bold text-error mb-4">UI Error</h1>
<pre class="bg-base-200 p-4 rounded">{detail}</pre>
</body></html>"#
        );
        (status, Html(html)).into_response()
    }
}

// ── Helpers ──────────────────────────────────────────────────────────────────

/// Acquire read lock on Tera and render a template.
/// The lock is held only for the duration of `render()` — not across the
/// async NCL export — so reloads are never blocked by slow nickel subprocess
/// calls.
pub(crate) async fn render(
    tera: &Arc<RwLock<tera::Tera>>,
    template: &str,
    ctx: &Context,
) -> Result<Html<String>, UiError> {
    let guard = tera.read().await;
    guard.render(template, ctx).map(Html).map_err(|e| {
        use std::error::Error;
        let mut chain = format!("{e}");
        let mut src = e.source();
        while let Some(s) = src {
            chain.push_str(&format!(" → {s}"));
            src = s.source();
        }
        tracing::error!(template, error = %chain, "tera render failed");
        UiError::Render(e)
    })
}

pub(crate) fn tera_ref(state: &AppState) -> Result<&Arc<RwLock<tera::Tera>>, UiError> {
    state.tera.as_ref().ok_or(UiError::NotConfigured)
}

async fn ncl_export(state: &AppState, relative_path: &str) -> Result<serde_json::Value, UiError> {
    let path = state.project_root.join(relative_path);
    let (value, _hit) = state
        .cache
        .export(&path, state.nickel_import_path.as_deref())
        .await
        .map_err(|e| UiError::NclExport {
            path: relative_path.to_string(),
            reason: e.to_string(),
        })?;
    Ok(value)
}

pub(crate) fn epoch_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

/// Format current UTC time as "HH:MM:SS UTC" for display in templates.
pub(crate) fn now_hms() -> String {
    let secs = epoch_secs();
    let h = (secs % 86400) / 3600;
    let m = (secs % 3600) / 60;
    let s = secs % 60;
    format!("{h:02}:{m:02}:{s:02} UTC")
}

// ── Auth helpers ─────────────────────────────────────────────────────────────

/// Map the HTTP auth role to a lowercase string for Tera context.
/// Templates gate admin-only sections on `current_role == "admin"`.
fn auth_role_str(auth: &AuthUser) -> &'static str {
    use crate::registry::Role;
    match auth.0.role {
        Role::Admin => "admin",
        Role::Viewer => "viewer",
    }
}

// ── Brand context ────────────────────────────────────────────────────────────

/// Resolve a logo config value to a URL.
/// Bare paths (no `/` or scheme prefix) are served from `{base_url}/assets/`.
fn resolve_logo_url(raw: &str, base_url: &str) -> String {
    if raw.starts_with('/') || raw.starts_with("http://") || raw.starts_with("https://") {
        raw.to_string()
    } else {
        format!("{base_url}/assets/{raw}")
    }
}

/// Load and export `.ontoref/config.ncl`, returning the full JSON value.
/// Returns `None` if the file doesn't exist or Nickel export fails.
async fn load_config_json(
    root: &std::path::Path,
    cache: &Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
) -> Option<serde_json::Value> {
    let config_path = root.join(".ontoref").join("config.ncl");
    if !config_path.exists() {
        return None;
    }
    match cache.export(&config_path, import_path).await {
        Ok((json, _)) => {
            tracing::info!(
                path = %config_path.display(),
                has_card = json.get("card").is_some(),
                card_tagline = json.get("card").and_then(|c| c.get("tagline")).and_then(|v| v.as_str()).unwrap_or(""),
                "config.ncl loaded"
            );
            Some(json)
        }
        Err(e) => {
            tracing::warn!(
                path = %config_path.display(),
                import_path = ?import_path,
                error = %e,
                "config.ncl export failed"
            );
            None
        }
    }
}

/// Load logo URLs from `.ontoref/config.ncl` ui section.
/// Returns `(logo_light_url, logo_dark_url)` — either may be `None`.
async fn load_logos(
    root: &std::path::Path,
    cache: &Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
    base_url: &str,
) -> (Option<String>, Option<String>) {
    let Some(json) = load_config_json(root, cache, import_path).await else {
        return (None, None);
    };
    let ui = json.get("ui");
    let logo = ui
        .and_then(|u| u.get("logo"))
        .and_then(|v| v.as_str())
        .map(|raw| resolve_logo_url(raw, base_url));
    let logo_dark = ui
        .and_then(|u| u.get("logo_dark"))
        .and_then(|v| v.as_str())
        .map(|raw| resolve_logo_url(raw, base_url));
    (logo, logo_dark)
}

/// Extract card data from a config JSON (from `.ontoref/config.ncl` `card`
/// field).
fn extract_card_from_config(json: &serde_json::Value) -> serde_json::Value {
    let Some(card) = json.get("card") else {
        return serde_json::Value::Null;
    };
    serde_json::json!({
        "tagline":     card.get("tagline").and_then(|v| v.as_str()).unwrap_or(""),
        "description": card.get("description").and_then(|v| v.as_str()).unwrap_or(""),
        "version":     card.get("version").and_then(|v| v.as_str()).unwrap_or(""),
        "status":      card.get("status").and_then(|v| v.as_str()).unwrap_or(""),
        "url":         card.get("url").and_then(|v| v.as_str()).unwrap_or(""),
        "repo":        card.get("repo").and_then(|v| v.as_str()).unwrap_or(""),
        "docs":        card.get("docs").and_then(|v| v.as_str()).unwrap_or(""),
        "tags":        card.get("tags").and_then(|v| v.as_array()).cloned().unwrap_or_default(),
        "features":    card.get("features").and_then(|v| v.as_array()).cloned().unwrap_or_default(),
    })
}

struct DomainCtx {
    has_origin: bool,
    origin_id: String,
    origin_name: String,
    origin_path: String,
    origin_integrated: bool,
    origin_slug: Option<String>,
    has_provides: bool,
    provides_id: String,
    provides_name: String,
    provides_kind: String,
    impl_projects: Vec<serde_json::Value>,
}

impl Default for DomainCtx {
    fn default() -> Self {
        Self {
            has_origin: false,
            origin_id: String::new(),
            origin_name: String::new(),
            origin_path: String::new(),
            origin_integrated: true,
            origin_slug: None,
            has_provides: false,
            provides_id: String::new(),
            provides_name: String::new(),
            provides_kind: String::new(),
            impl_projects: Vec::new(),
        }
    }
}

/// Resolve domain_origin and domain_provides context from a manifest JSON
/// value.
async fn resolve_domain_ctx(
    manifest_json: &serde_json::Value,
    registry: &Arc<crate::registry::ProjectRegistry>,
) -> DomainCtx {
    let mut dc = DomainCtx::default();
    if let Some(origin) = manifest_json.get("domain_origin") {
        dc.has_origin = true;
        dc.origin_id = origin
            .get("id")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.origin_name = origin
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.origin_path = origin
            .get("path")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.origin_integrated = origin
            .get("integrated")
            .and_then(|v| v.as_bool())
            .unwrap_or(true);
        if !dc.origin_path.is_empty() {
            let target = std::path::PathBuf::from(&dc.origin_path);
            dc.origin_slug = registry
                .all()
                .into_iter()
                .find(|pc| pc.root == target)
                .map(|pc| pc.slug.clone());
        }
    }
    if let Some(provides) = manifest_json.get("domain_provides") {
        dc.has_provides = true;
        dc.provides_id = provides
            .get("id")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.provides_name = provides
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        dc.provides_kind = provides
            .get("kind")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        for proj_ctx in registry.all() {
            let mpath = proj_ctx.root.join(".ontology").join("manifest.ncl");
            if !mpath.exists() {
                continue;
            }
            let Ok((mj, _)) = proj_ctx
                .cache
                .export(&mpath, proj_ctx.import_path.as_deref())
                .await
            else {
                continue;
            };
            let matches = mj
                .get("domain_origin")
                .and_then(|o| o.get("id"))
                .and_then(|v| v.as_str())
                == Some(dc.provides_id.as_str());
            if matches {
                dc.impl_projects
                    .push(serde_json::json!({ "slug": proj_ctx.slug }));
            }
        }
    }
    dc
}

/// Insert logo and MCP metadata into a Tera context.
/// Logos are loaded from `.ontoref/config.ncl`; MCP availability is
/// compile-time.
pub(crate) async fn insert_brand_ctx(
    ctx: &mut Context,
    root: &std::path::Path,
    cache: &Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
    base_url: &str,
    registry: &Arc<crate::registry::ProjectRegistry>,
) {
    let (logo, logo_dark) = load_logos(root, cache, import_path, base_url).await;
    ctx.insert("logo", &logo);
    ctx.insert("logo_dark", &logo_dark);
    insert_mcp_ctx(ctx);
    // Expose repo + docs URLs from card for file link generation in templates.
    let (repo_url, docs_url) = if let Some(json) = load_config_json(root, cache, import_path).await
    {
        let card = json.get("card");
        let repo = card
            .and_then(|c| c.get("repo"))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let docs = card
            .and_then(|c| c.get("docs"))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        (repo, docs)
    } else {
        (String::new(), String::new())
    };
    ctx.insert("card_repo", &repo_url);
    ctx.insert("card_docs", &docs_url);
    // Domain extension availability — drives conditional nav rendering in
    // base.html. personal/career: file presence (only PersonalOntology projects
    // declare these). provisioning: explicit opt-in via .ontoref/config.ncl
    // `domain_provisioning = true`.   Using an opt-in rather than repo_kind
    // avoids showing Provisioning for tool projects   (e.g. ontoref itself)
    // that happen to be DevWorkspace but are not managed workspaces.
    ctx.insert(
        "has_personal",
        &root.join(".ontology").join("personal.ncl").exists(),
    );
    ctx.insert(
        "has_career",
        &root.join(".ontology").join("career.ncl").exists(),
    );
    let is_provisioning = load_config_json(root, cache, import_path)
        .await
        .and_then(|c| c.get("domain_provisioning").and_then(|v| v.as_bool()))
        .unwrap_or(false);
    ctx.insert("has_provisioning", &is_provisioning);

    // Domain ontology layer — manifest.domain_origin and manifest.domain_provides.
    // Surfaces the two-layer ontology distinction in the UI header and sidebar.
    let manifest_path = root.join(".ontology").join("manifest.ncl");
    let domain = if manifest_path.exists() {
        match cache.export(&manifest_path, import_path).await {
            Ok((mj, _)) => resolve_domain_ctx(&mj, registry).await,
            Err(_) => DomainCtx::default(),
        }
    } else {
        DomainCtx::default()
    };
    ctx.insert("has_domain_origin", &domain.has_origin);
    ctx.insert("domain_origin_id", &domain.origin_id);
    ctx.insert("domain_origin_name", &domain.origin_name);
    ctx.insert("domain_origin_path", &domain.origin_path);
    ctx.insert("domain_origin_integrated", &domain.origin_integrated);
    ctx.insert("domain_origin_slug", &domain.origin_slug);
    ctx.insert("has_domain_provides", &domain.has_provides);
    ctx.insert("domain_provides_id", &domain.provides_id);
    ctx.insert("domain_provides_name", &domain.provides_name);
    ctx.insert("domain_provides_kind", &domain.provides_kind);
    ctx.insert("domain_impl_projects", &domain.impl_projects);
}

/// Insert MCP metadata and daemon version into a Tera context.
pub(crate) fn insert_mcp_ctx(ctx: &mut Context) {
    ctx.insert("daemon_version", env!("CARGO_PKG_VERSION"));
    #[cfg(feature = "mcp")]
    {
        ctx.insert("mcp_active", &true);
        ctx.insert(
            "mcp_tools",
            &[
                // discovery
                "ontoref_help",
                "ontoref_list_projects",
                "ontoref_set_project",
                // search + retrieval
                "ontoref_search",
                "ontoref_get",
                "ontoref_get_node",
                "ontoref_get_adr",
                "ontoref_get_mode",
                // project state
                "ontoref_status",
                "ontoref_describe",
                "ontoref_guides",
                // ontology
                "ontoref_list_adrs",
                "ontoref_list_modes",
                "ontoref_list_ontology_extensions",
                "ontoref_get_ontology_extension",
                "ontoref_constraints",
                // backlog + actions
                "ontoref_backlog_list",
                "ontoref_backlog",
                "ontoref_action_list",
                "ontoref_action_add",
                // validation
                "ontoref_validate_adrs",
                "ontoref_validate",
                "ontoref_impact",
                // qa + bookmarks
                "ontoref_qa_list",
                "ontoref_qa_add",
                "ontoref_bookmark_list",
                "ontoref_bookmark_add",
            ],
        );
    }
    #[cfg(not(feature = "mcp"))]
    {
        ctx.insert("mcp_active", &false);
        ctx.insert("mcp_tools", &Vec::<&str>::new());
    }
}

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

pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;

    let hits = state.cache.hit_count();
    let misses = state.cache.miss_count();
    let total = hits + misses;
    let hit_rate = if total > 0 {
        format!("{:.1}%", hits as f64 / total as f64 * 100.0)
    } else {
        "—".to_string()
    };

    let mut ctx = Context::new();
    ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs());
    ctx.insert("cache_entries", &state.cache.len());
    ctx.insert("cache_hits", &hits);
    ctx.insert("cache_misses", &misses);
    ctx.insert("cache_hit_rate", &hit_rate);
    ctx.insert("active_actors", &state.actors.count());
    ctx.insert(
        "notification_count",
        &state.notifications.all_recent().len(),
    );
    ctx.insert("project_root", &state.project_root.display().to_string());
    ctx.insert("now", &epoch_secs());
    ctx.insert("now_hms", &now_hms());
    ctx.insert("base_url", "/ui");
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;

    render(tera, "pages/dashboard.html", &ctx).await
}

// ── Graph ────────────────────────────────────────────────────────────────────

pub async fn graph(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;

    let core_json = match ncl_export(&state, ".ontology/core.ncl").await {
        Ok(v) => v,
        Err(e) => {
            warn!(error = %e, "failed to load .ontology/core.ncl for graph");
            serde_json::json!({ "nodes": [], "edges": [] })
        }
    };

    let graph_json = serde_json::to_string(&core_json).unwrap_or_else(|_| "{}".to_string());

    let mut ctx = Context::new();
    ctx.insert("graph_json", &graph_json);
    ctx.insert("project_root", &state.project_root.display().to_string());
    ctx.insert("base_url", "/ui");
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;

    render(tera, "pages/graph.html", &ctx).await
}

// ── Sessions ─────────────────────────────────────────────────────────────────

#[derive(Debug, Serialize)]
struct SessionRow {
    token: String,
    actor_type: String,
    hostname: String,
    pid: u32,
    project: String,
    role: String,
    registered_ago: u64,
    last_seen_ago: u64,
    pending_notifications: u64,
    has_preferences: bool,
}

pub async fn sessions(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;

    let now = epoch_secs();
    let rows: Vec<SessionRow> = state
        .actors
        .list()
        .into_iter()
        .map(|(token, s)| SessionRow {
            token,
            actor_type: s.actor_type,
            hostname: s.hostname,
            pid: s.pid,
            project: s.project,
            role: s.role,
            registered_ago: now.saturating_sub(s.registered_at),
            last_seen_ago: now.saturating_sub(s.last_seen),
            pending_notifications: s.pending_notifications,
            has_preferences: !s
                .preferences
                .as_object()
                .map(|m| m.is_empty())
                .unwrap_or(true),
        })
        .collect();

    let mut ctx = Context::new();
    ctx.insert("sessions", &rows);
    ctx.insert("total", &rows.len());
    ctx.insert("base_url", "/ui");
    ctx.insert("current_role", "admin");
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;

    render(tera, "pages/sessions.html", &ctx).await
}

// ── Notifications
// ─────────────────────────────────────────────────────────────

pub async fn notifications_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;

    let now = epoch_secs();

    #[derive(Serialize)]
    struct NotifRow {
        id: u64,
        project: String,
        event: String,
        files: Vec<String>,
        age_secs: u64,
        source_actor: Option<String>,
    }

    let rows: Vec<NotifRow> = state
        .notifications
        .all_recent()
        .into_iter()
        .map(|n| NotifRow {
            id: n.id,
            project: n.project,
            event: format!("{:?}", n.event),
            files: n.files,
            age_secs: now.saturating_sub(n.timestamp),
            source_actor: n.source_actor,
        })
        .collect();

    let mut ctx = Context::new();
    ctx.insert("notifications", &rows);
    ctx.insert("total", &rows.len());
    ctx.insert("base_url", "/ui");
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;

    render(tera, "pages/notifications.html", &ctx).await
}

// ── Search ────────────────────────────────────────────────────────────────────

pub async fn search_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let bookmarks = load_bookmark_entries(
        &state.cache,
        &state.project_root,
        state.nickel_import_path.as_deref(),
    )
    .await;
    let mut ctx = Context::new();
    ctx.insert("base_url", "/ui");
    ctx.insert("slug", &Option::<String>::None);
    ctx.insert("server_bookmarks", &bookmarks);
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;
    render(tera, "pages/search.html", &ctx).await
}

pub async fn search_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let base_url = format!("/ui/{slug}");
    let bookmarks = load_bookmark_entries(
        &ctx_ref.cache,
        &ctx_ref.root,
        ctx_ref.import_path.as_deref(),
    )
    .await;
    let mut ctx = Context::new();
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &auth_role_str(&auth));
    ctx.insert("server_bookmarks", &bookmarks);
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;
    render(tera, "pages/search.html", &ctx).await
}

// ── Modes ─────────────────────────────────────────────────────────────────────

pub async fn modes(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;

    let modes_dir = state.project_root.join("reflection").join("modes");
    let mut mode_entries: Vec<serde_json::Value> = Vec::new();

    if let Ok(entries) = std::fs::read_dir(&modes_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let rel = path
                .strip_prefix(&state.project_root)
                .unwrap_or(&path)
                .to_string_lossy()
                .to_string();

            match state
                .cache
                .export(&path, state.nickel_import_path.as_deref())
                .await
            {
                Ok((mut json, _)) => {
                    json.as_object_mut()
                        .map(|o| o.insert("_file".to_string(), serde_json::Value::String(rel)));
                    mode_entries.push(json);
                }
                Err(e) => {
                    warn!(path = %rel, error = %e, "failed to export reflection mode");
                    mode_entries.push(serde_json::json!({
                        "_file": rel,
                        "id": path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"),
                        "_error": e.to_string(),
                    }));
                }
            }
        }
    }

    mode_entries.sort_by_key(|v| {
        v.get("id")
            .and_then(|i| i.as_str())
            .unwrap_or("")
            .to_string()
    });

    let showcase = detect_showcase(&state.project_root, "/ui");
    let generated = detect_generated(&state.project_root, "/ui");

    let mut ctx = Context::new();
    ctx.insert("modes", &mode_entries);
    ctx.insert("total", &mode_entries.len());
    ctx.insert("showcase", &showcase);
    ctx.insert("generated", &generated);
    ctx.insert("base_url", "/ui");
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;

    render(tera, "pages/modes.html", &ctx).await
}

// ── Project Picker
// ────────────────────────────────────────────────────────────

pub async fn project_picker(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;

    let registry = &state.registry;

    let now = epoch_secs();
    let mut projects: Vec<serde_json::Value> = Vec::new();

    for proj in registry.all() {
        // Sessions
        let actor_list = proj.actors.list();
        let sessions: Vec<serde_json::Value> = actor_list
            .iter()
            .map(|(token, s)| {
                serde_json::json!({
                    "token": &token[..8.min(token.len())],
                    "actor_type": s.actor_type,
                    "hostname": s.hostname,
                    "pid": s.pid,
                    "last_seen_ago": now.saturating_sub(s.last_seen),
                })
            })
            .collect();

        // Notifications
        let notif_list = proj.notifications.all_recent();
        let notifications: Vec<serde_json::Value> = notif_list
            .iter()
            .take(8)
            .map(|n| {
                serde_json::json!({
                    "id": n.id,
                    "event": format!("{:?}", n.event),
                    "files": n.files.iter().take(3).collect::<Vec<_>>(),
                    "age_secs": now.saturating_sub(n.timestamp),
                })
            })
            .collect();

        // Backlog — optional NCL export
        let backlog_path = proj.root.join("reflection").join("backlog.ncl");
        let (backlog_items, backlog_open) = if backlog_path.exists() {
            match proj
                .cache
                .export(&backlog_path, proj.import_path.as_deref())
                .await
            {
                Ok((json, _)) => {
                    let items: Vec<serde_json::Value> = json
                        .get("items")
                        .and_then(|v| v.as_array())
                        .map(|arr| {
                            arr.iter()
                                .map(|it| serde_json::json!({
                                    "id":       it.get("id").and_then(|v| v.as_str()).unwrap_or(""),
                                    "title":    it.get("title").and_then(|v| v.as_str()).unwrap_or(""),
                                    "status":   it.get("status").and_then(|v| v.as_str()).unwrap_or(""),
                                    "priority": it.get("priority").and_then(|v| v.as_str()).unwrap_or(""),
                                    "kind":     it.get("kind").and_then(|v| v.as_str()).unwrap_or(""),
                                }))
                                .collect()
                        })
                        .unwrap_or_default();
                    let open = items
                        .iter()
                        .filter(|it| it.get("status").and_then(|v| v.as_str()) == Some("Open"))
                        .count();
                    (items, open)
                }
                Err(_) => (vec![], 0),
            }
        } else {
            (vec![], 0)
        };

        // Manifest — layers, operational_modes, default_mode, repo_kind
        let manifest_path = proj.root.join(".ontology").join("manifest.ncl");
        let (layers, op_modes, default_mode, repo_kind) = if manifest_path.exists() {
            match proj
                .cache
                .export(&manifest_path, proj.import_path.as_deref())
                .await
            {
                Ok((json, _)) => {
                    let layers: Vec<serde_json::Value> = json
                        .get("layers")
                        .and_then(|v| v.as_array())
                        .map(|arr| arr.iter().map(|l| serde_json::json!({
                            "id":          l.get("id").and_then(|v| v.as_str()).unwrap_or(""),
                            "description": l.get("description").and_then(|v| v.as_str()).unwrap_or(""),
                        })).collect())
                        .unwrap_or_default();
                    let op_modes: Vec<serde_json::Value> = json
                        .get("operational_modes")
                        .and_then(|v| v.as_array())
                        .map(|arr| arr.iter().map(|m| serde_json::json!({
                            "id":          m.get("id").and_then(|v| v.as_str()).unwrap_or(""),
                            "description": m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
                        })).collect())
                        .unwrap_or_default();
                    let default_mode = json
                        .get("default_mode")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    let repo_kind = json
                        .get("repo_kind")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    (layers, op_modes, default_mode, repo_kind)
                }
                Err(_) => (vec![], vec![], String::new(), String::new()),
            }
        } else {
            (vec![], vec![], String::new(), String::new())
        };

        // card — loaded from `.ontoref/config.ncl` `card` field (which imports
        // ../card.ncl)
        let config_json =
            load_config_json(&proj.root, &proj.cache, proj.import_path.as_deref()).await;
        let card = config_json
            .as_ref()
            .map(extract_card_from_config)
            .unwrap_or(serde_json::Value::Null);
        tracing::debug!(
            slug = %proj.slug,
            import_path = ?proj.import_path,
            config_loaded = config_json.is_some(),
            card_tagline = card.get("tagline").and_then(|v| v.as_str()).unwrap_or(""),
            "project card loaded"
        );

        // Description — first meaningful text line from README.md (fallback when no
        // card)
        let description = if card
            .get("tagline")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .is_empty()
        {
            readme_description(&proj.root)
        } else {
            String::new()
        };

        let proj_base = format!("/ui/{}", proj.slug);
        let showcase = detect_showcase(&proj.root, &proj_base);
        let generated = detect_generated(&proj.root, &proj_base);

        let repos = git_remotes(&proj.root);
        let opmode = if proj.push_only { "push" } else { "daemon" };

        projects.push(serde_json::json!({
            "slug":          proj.slug,
            "root":          proj.root.display().to_string(),
            "auth":          proj.auth_enabled(),
            "card":          card,
            "description":   description,
            "default_mode":  default_mode,
            "repo_kind":     repo_kind,
            "repos":         repos,
            "session_count": sessions.len(),
            "sessions":      sessions,
            "notif_count":   notifications.len(),
            "notifications": notifications,
            "backlog_open":  backlog_open,
            "backlog_items": backlog_items,
            "layers":        layers,
            "op_modes":      op_modes,
            "showcase":      showcase,
            "generated":     generated,
            "opmode":        opmode,
        }));
    }

    let mut ctx = Context::new();
    ctx.insert("projects", &projects);
    ctx.insert("base_url", "/ui");
    ctx.insert("hide_project_nav", &true);
    insert_mcp_ctx(&mut ctx);
    render(tera, "pages/project_picker.html", &ctx).await
}

/// HTMX lazy-load endpoint — triggered by `hx-trigger="revealed"` on each
/// project card. Returns an HTML fragment: the panel as the main swap target
/// plus the badge as an out-of-band swap (`hx-swap-oob`).
pub async fn pending_migrations_fragment_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    _auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let migrations = load_pending_migrations(&ctx_ref.root).await;
    let mut ctx = Context::new();
    ctx.insert("migrations", &migrations);
    ctx.insert("slug", &slug);
    render(tera, "partials/migrations_fragment.html", &ctx).await
}

/// Read git remote names+URLs from `{root}/.git/config` by parsing the ini file
/// directly. Returns `[{ "name": "origin", "url": "git@github.com:..." },
/// ...]`.
pub(crate) fn git_remotes(root: &std::path::Path) -> Vec<serde_json::Value> {
    let Ok(content) = std::fs::read_to_string(root.join(".git").join("config")) else {
        return vec![];
    };
    let mut remotes = Vec::new();
    let mut current_name: Option<String> = None;
    for line in content.lines() {
        let t = line.trim();
        if let Some(inner) = t
            .strip_prefix("[remote \"")
            .and_then(|s| s.strip_suffix("\"]"))
        {
            current_name = Some(inner.to_string());
        } else if t.starts_with('[') {
            current_name = None;
        } else if let Some(name) = &current_name {
            if let Some(url) = t.strip_prefix("url = ") {
                remotes.push(serde_json::json!({ "name": name, "url": url }));
                current_name = None;
            }
        }
    }
    remotes
}

/// Extract the first meaningful prose line from README.md.
/// Skips HTML tags, Markdown headings, image/badge lines, blockquotes, and
/// blank lines.
pub(crate) fn readme_description(root: &std::path::Path) -> String {
    let readme = root.join("README.md");
    let Ok(content) = std::fs::read_to_string(&readme) else {
        return String::new();
    };
    for raw in content.lines() {
        let line = raw.trim();
        if line.is_empty() {
            continue;
        }
        // Skip: HTML tags, headings, images, badges, horizontal rules, blockquotes
        if line.starts_with('<')
            || line.starts_with('#')
            || line.starts_with('!')
            || line.starts_with("---")
            || line.starts_with("===")
            || line.contains("badge")
            || line.contains("img.shields")
            || line.contains("<img")
            || line.starts_with('>')
        // blockquote
        {
            continue;
        }
        // Strip leading Markdown bold/italic markers and quote chars
        let text = line.trim_start_matches(['*', '_', '`', '>', ' ']);
        if text.len() > 10 {
            // Truncate at 160 chars for the card
            return if text.len() > 160 {
                format!("{}…", &text[..text.floor_char_boundary(160)])
            } else {
                text.to_string()
            };
        }
    }
    String::new()
}

fn count_ncl_files(dir: &std::path::Path) -> usize {
    std::fs::read_dir(dir)
        .map(|rd| {
            rd.flatten()
                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
                .count()
        })
        .unwrap_or(0)
}

// ── Multi-project handlers
// ────────────────────────────────────────────────────

pub async fn dashboard_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let hits = ctx_ref.cache.hit_count();
    let misses = ctx_ref.cache.miss_count();
    let total = hits + misses;
    let hit_rate = if total > 0 {
        format!("{:.1}%", hits as f64 / total as f64 * 100.0)
    } else {
        "—".to_string()
    };

    let base_url = format!("/ui/{slug}");
    let backlog_path = ctx_ref.root.join("reflection").join("backlog.ncl");
    let backlog_items = if backlog_path.exists() {
        load_backlog_items(
            &ctx_ref.cache,
            &backlog_path,
            ctx_ref.import_path.as_deref(),
        )
        .await
    } else {
        vec![]
    };
    let backlog = backlog_stats(&backlog_items);
    let adr_count = count_ncl_files(&ctx_ref.root.join("adrs"));
    let mode_count = count_ncl_files(&ctx_ref.root.join("reflection").join("modes"));

    // ── Project identity (same data as project_picker card) ──────────────────
    let config_json = load_config_json(
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
    )
    .await;
    let card = config_json
        .as_ref()
        .map(extract_card_from_config)
        .unwrap_or(serde_json::Value::Null);
    let description = if card
        .get("tagline")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .is_empty()
    {
        readme_description(&ctx_ref.root)
    } else {
        String::new()
    };
    let manifest_path = ctx_ref.root.join(".ontology").join("manifest.ncl");
    let (layers, op_modes, default_mode, repo_kind) = if manifest_path.exists() {
        match ctx_ref
            .cache
            .export(&manifest_path, ctx_ref.import_path.as_deref())
            .await
        {
            Ok((json, _)) => {
                let layers: Vec<serde_json::Value> = json
                    .get("layers")
                    .and_then(|v| v.as_array())
                    .map(|arr| {
                        arr.iter()
                            .map(|l| {
                                serde_json::json!({
                                    "id":          l.get("id").and_then(|v| v.as_str()).unwrap_or(""),
                                    "description": l.get("description").and_then(|v| v.as_str()).unwrap_or(""),
                                })
                            })
                            .collect()
                    })
                    .unwrap_or_default();
                let op_modes: Vec<serde_json::Value> = json
                    .get("operational_modes")
                    .and_then(|v| v.as_array())
                    .map(|arr| {
                        arr.iter()
                            .map(|m| {
                                serde_json::json!({
                                    "id":          m.get("id").and_then(|v| v.as_str()).unwrap_or(""),
                                    "description": m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
                                })
                            })
                            .collect()
                    })
                    .unwrap_or_default();
                let default_mode = json
                    .get("default_mode")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let repo_kind = json
                    .get("repo_kind")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                (layers, op_modes, default_mode, repo_kind)
            }
            Err(_) => (vec![], vec![], String::new(), String::new()),
        }
    } else {
        (vec![], vec![], String::new(), String::new())
    };
    let repos = git_remotes(&ctx_ref.root);
    let showcase = detect_showcase(&ctx_ref.root, &base_url);
    let generated = detect_generated(&ctx_ref.root, &base_url);
    let pending_migrations = load_pending_migrations(&ctx_ref.root).await;

    let mut ctx = Context::new();
    ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs());
    ctx.insert("cache_entries", &ctx_ref.cache.len());
    ctx.insert("cache_hits", &hits);
    ctx.insert("cache_misses", &misses);
    ctx.insert("cache_hit_rate", &hit_rate);
    ctx.insert("active_actors", &ctx_ref.actors.count());
    ctx.insert(
        "notification_count",
        &ctx_ref.notifications.all_recent().len(),
    );
    ctx.insert("project_root", &ctx_ref.root.display().to_string());
    ctx.insert("now", &epoch_secs());
    ctx.insert("now_hms", &now_hms());
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("backlog", &backlog);
    ctx.insert("adr_count", &adr_count);
    ctx.insert("mode_count", &mode_count);
    ctx.insert("current_role", &auth_role_str(&auth));
    ctx.insert("card", &card);
    ctx.insert("description", &description);
    ctx.insert("repo_kind", &repo_kind);
    ctx.insert("repos", &repos);
    ctx.insert("layers", &layers);
    ctx.insert("op_modes", &op_modes);
    ctx.insert("default_mode", &default_mode);
    ctx.insert("pending_migrations", &pending_migrations);
    ctx.insert("showcase", &showcase);
    ctx.insert("generated", &generated);

    let file_versions: std::collections::BTreeMap<String, u64> = ctx_ref
        .file_versions
        .iter()
        .map(|r| {
            let name = r
                .key()
                .file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .into_owned();
            (name, *r.value())
        })
        .collect();
    ctx.insert("file_versions", &file_versions);

    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/dashboard.html", &ctx).await
}

/// HTMX polling endpoint — returns only the stats bar fragment.
/// Called every 10s by the dashboard's `hx-get` trigger.
pub async fn dashboard_stats_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    _auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let hits = ctx_ref.cache.hit_count();
    let misses = ctx_ref.cache.miss_count();
    let total = hits + misses;
    let hit_rate = if total > 0 {
        format!("{:.1}%", hits as f64 / total as f64 * 100.0)
    } else {
        "—".to_string()
    };

    let mut ctx = Context::new();
    ctx.insert("uptime_secs", &state.started_at.elapsed().as_secs());
    ctx.insert("cache_entries", &ctx_ref.cache.len());
    ctx.insert("cache_hits", &hits);
    ctx.insert("cache_misses", &misses);
    ctx.insert("cache_hit_rate", &hit_rate);
    ctx.insert("active_actors", &ctx_ref.actors.count());
    ctx.insert(
        "notification_count",
        &ctx_ref.notifications.all_recent().len(),
    );
    ctx.insert("base_url", &format!("/ui/{slug}"));
    render(tera, "partials/dashboard_stats.html", &ctx).await
}

pub async fn api_catalog_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let base_url = format!("/ui/{slug}");

    // The #[onto_api] catalog is populated via inventory::submit! at link time.
    // For the primary project (ontoref itself) we read from the live inventory.
    // For consumer projects (separate binaries) we read api-catalog.json from
    // their project root — generated by `just export-api-catalog` in that project.
    let is_primary = slug == state.registry.primary_slug();

    // Build Vec<{name, routes}> — one entry per catalog file (or one for primary).
    let grouped: Vec<serde_json::Value> = if is_primary {
        let routes: Vec<serde_json::Value> = crate::api_catalog::catalog()
            .into_iter()
            .map(|r| {
                let params: Vec<serde_json::Value> = r
                    .params
                    .iter()
                    .map(|p| {
                        serde_json::json!({
                            "name": p.name,
                            "type": p.kind,
                            "constraint": p.constraint,
                            "description": p.description,
                        })
                    })
                    .collect();
                serde_json::json!({
                    "method":       r.method,
                    "path":         r.path,
                    "description":  r.description,
                    "auth":         r.auth,
                    "actors":       r.actors,
                    "params":       params,
                    "tags":         r.tags,
                    "feature":      r.feature,
                    "source_file":  r.source_file,
                })
            })
            .collect();
        vec![serde_json::json!({"name": "ontoref-daemon", "routes": routes})]
    } else {
        read_project_api_catalogs(
            &ctx_ref.root,
            &ctx_ref.cache,
            ctx_ref.import_path.as_deref(),
        )
        .await
        .into_iter()
        .map(|(name, routes)| serde_json::json!({"name": name, "routes": routes}))
        .collect()
    };

    let route_count: usize = grouped
        .iter()
        .filter_map(|g| g["routes"].as_array())
        .map(|a| a.len())
        .sum();
    let catalogs_json = serde_json::to_string(&grouped).unwrap_or_else(|_| "[]".to_string());

    let mut ctx = Context::new();
    ctx.insert("catalogs_json", &catalogs_json);
    ctx.insert("route_count", &route_count);
    ctx.insert("is_primary", &is_primary);
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &auth_role_str(&auth));
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/api_catalog.html", &ctx).await
}

pub async fn graph_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let core_path = ctx_ref.root.join(".ontology/core.ncl");
    let mut graph = match ctx_ref
        .cache
        .export(&core_path, ctx_ref.import_path.as_deref())
        .await
    {
        Ok((v, _)) => v,
        Err(e) => {
            warn!(error = %e, slug = %slug, "failed to load .ontology/core.ncl for graph");
            serde_json::json!({ "nodes": [], "edges": [] })
        }
    };

    // Merge api-catalog artifacts as Crate + Route nodes/edges.
    let catalogs = read_project_api_catalogs(
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
    )
    .await;
    let (api_nodes, api_edges) = api_catalog_graph_nodes(&catalogs);

    // Merge FSM state dimensions from .ontology/state.ncl.
    let state_path = ctx_ref.root.join(".ontology/state.ncl");
    let (state_nodes, state_edges) = if state_path.exists() {
        match ctx_ref
            .cache
            .export(&state_path, ctx_ref.import_path.as_deref())
            .await
        {
            Ok((v, _)) => state_graph_nodes(&v),
            Err(e) => {
                warn!(error = %e, "failed to load .ontology/state.ncl for graph");
                (vec![], vec![])
            }
        }
    } else {
        (vec![], vec![])
    };

    // Merge workspace crate dependency layer.
    let (ws_nodes, ws_edges) = workspace_crate_nodes(&ctx_ref.root);

    // Merge manifest config sections + requirements.
    let manifest_path = ctx_ref.root.join(".ontology/manifest.ncl");
    let (manifest_nodes, manifest_edges) = if manifest_path.exists() {
        match ctx_ref
            .cache
            .export(&manifest_path, ctx_ref.import_path.as_deref())
            .await
        {
            Ok((v, _)) => manifest_graph_nodes(&v),
            Err(e) => {
                warn!(error = %e, "failed to load .ontology/manifest.ncl for graph");
                (vec![], vec![])
            }
        }
    } else {
        (vec![], vec![])
    };

    let all_extra_nodes = api_nodes
        .into_iter()
        .chain(state_nodes)
        .chain(ws_nodes)
        .chain(manifest_nodes)
        .collect::<Vec<_>>();
    let all_extra_edges = api_edges
        .into_iter()
        .chain(state_edges)
        .chain(ws_edges)
        .chain(manifest_edges)
        .collect::<Vec<_>>();

    if !all_extra_nodes.is_empty() {
        if let Some(nodes) = graph.get_mut("nodes").and_then(|v| v.as_array_mut()) {
            nodes.extend(all_extra_nodes);
        }
        if let Some(edges) = graph.get_mut("edges").and_then(|v| v.as_array_mut()) {
            edges.extend(all_extra_edges);
        }
    }

    let graph_json = serde_json::to_string(&graph).unwrap_or_else(|_| "{}".to_string());

    let base_url = format!("/ui/{slug}");
    let mut ctx = Context::new();
    ctx.insert("graph_json", &graph_json);
    ctx.insert("project_root", &ctx_ref.root.display().to_string());
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &auth_role_str(&auth));
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/graph.html", &ctx).await
}

pub async fn sessions_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    _auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let now = epoch_secs();
    let rows: Vec<SessionRow> = ctx_ref
        .actors
        .list()
        .into_iter()
        .map(|(token, s)| SessionRow {
            token,
            actor_type: s.actor_type,
            hostname: s.hostname,
            pid: s.pid,
            project: s.project,
            role: s.role,
            registered_ago: now.saturating_sub(s.registered_at),
            last_seen_ago: now.saturating_sub(s.last_seen),
            pending_notifications: s.pending_notifications,
            has_preferences: !s
                .preferences
                .as_object()
                .map(|m| m.is_empty())
                .unwrap_or(true),
        })
        .collect();

    let base_url = format!("/ui/{slug}");
    let http_role = auth_role_str(&_auth);
    let mut ctx = Context::new();
    ctx.insert("sessions", &rows);
    ctx.insert("total", &rows.len());
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &http_role);
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/sessions.html", &ctx).await
}

pub async fn notifications_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let now = epoch_secs();

    #[derive(Serialize)]
    struct NotifRow {
        id: u64,
        project: String,
        event: String,
        is_custom: bool,
        files: Vec<String>,
        age_secs: u64,
        source_actor: Option<String>,
        custom_kind: Option<String>,
        custom_title: Option<String>,
        custom_payload: Option<serde_json::Value>,
        source_project: Option<String>,
    }

    let rows: Vec<NotifRow> = ctx_ref
        .notifications
        .all_recent()
        .into_iter()
        .map(|n| {
            let is_custom = matches!(n.event, crate::notifications::NotificationEvent::Custom);
            NotifRow {
                id: n.id,
                project: n.project,
                event: format!("{:?}", n.event),
                is_custom,
                files: n.files,
                age_secs: now.saturating_sub(n.timestamp),
                source_actor: n.source_actor,
                custom_kind: n.custom_kind,
                custom_title: n.custom_title,
                custom_payload: n.custom_payload,
                source_project: n.source_project,
            }
        })
        .collect();

    // All other registered projects for the emit-to selector
    let other_projects: Vec<String> = state
        .registry
        .all()
        .into_iter()
        .map(|p| p.slug.clone())
        .collect();

    let base_url = format!("/ui/{slug}");
    let mut ctx = Context::new();
    ctx.insert("notifications", &rows);
    ctx.insert("total", &rows.len());
    let http_role = auth_role_str(&auth);
    // Viewers can see notifications but cannot emit them — template gates on
    // current_role.
    let empty: Vec<String> = vec![];
    let visible_projects: &Vec<String> = if http_role == "admin" {
        &other_projects
    } else {
        &empty
    };
    ctx.insert("other_projects", visible_projects);
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &http_role);
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/notifications.html", &ctx).await
}

// ── Notification emit
// ─────────────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct EmitNotifForm {
    pub target_slug: String,
    pub kind: String,
    pub title: String,
    #[serde(default)]
    pub payload: String,
    #[serde(default)]
    pub source_actor: String,
}

// ── Notification DAG action execution ────────────────────────────────────────

#[derive(Deserialize)]
pub struct NotifActionForm {
    pub action_id: String,
}

pub async fn notification_action_mp(
    State(state): State<AppState>,
    Path((slug, notif_id)): Path<(String, u64)>,
    _auth: AuthUser,
    headers: axum::http::HeaderMap,
    Form(form): Form<NotifActionForm>,
) -> Result<Response, UiError> {
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let notif = ctx_ref
        .notifications
        .get_one(notif_id)
        .ok_or_else(|| UiError::BadRequest(format!("notification {notif_id} not found")))?;

    let actions: Vec<serde_json::Value> = notif
        .custom_payload
        .as_ref()
        .and_then(|p| p.get("actions"))
        .and_then(|a| a.as_array())
        .cloned()
        .unwrap_or_default();

    let action = actions
        .iter()
        .find(|a| a.get("id").and_then(|v| v.as_str()) == Some(form.action_id.as_str()))
        .ok_or_else(|| UiError::BadRequest(format!("action '{}' not found", form.action_id)))?;

    let mode = action
        .get("mode")
        .and_then(|v| v.as_str())
        .unwrap_or("manual");
    let dag_id = action.get("dag").and_then(|v| v.as_str()).unwrap_or("");

    // ── backlog review actions ────────────────────────────────────────────────
    if mode == "backlog_approve" {
        let payload = notif.custom_payload.as_ref().ok_or_else(|| {
            UiError::BadRequest("backlog_approve: notification has no payload".into())
        })?;
        let item_id = payload
            .get("item_id")
            .and_then(|v| v.as_str())
            .ok_or_else(|| UiError::BadRequest("backlog_approve: missing item_id".into()))?;
        let proposed = payload
            .get("proposed_status")
            .and_then(|v| v.as_str())
            .ok_or_else(|| {
                UiError::BadRequest("backlog_approve: missing proposed_status".into())
            })?;
        let backlog_path = ctx_ref.root.join("reflection").join("backlog.ncl");
        let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
        super::backlog_ncl::update_status(&backlog_path, item_id, proposed, &today_iso()).map_err(
            |e| UiError::NclExport {
                path: "backlog.ncl".into(),
                reason: e.to_string(),
            },
        )?;
        ctx_ref
            .cache
            .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path));
        if headers.contains_key("hx-request") {
            return Ok(Html(format!(
                r#"<span class="badge badge-xs badge-success gap-1">✓ approved → {proposed}</span>"#
            ))
            .into_response());
        }
        return Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response());
    }

    if mode == "backlog_reject" {
        if headers.contains_key("hx-request") {
            return Ok(Html(
                r#"<span class="badge badge-xs badge-ghost gap-1">✕ rejected</span>"#.to_string(),
            )
            .into_response());
        }
        return Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response());
    }

    // ── DAG actions ───────────────────────────────────────────────────────────
    if !dag_id.is_empty() && matches!(mode, "auto" | "semi") {
        let ontoref_bin = ctx_ref.root.join("ontoref");
        if ontoref_bin.exists() {
            tokio::process::Command::new(&ontoref_bin)
                .arg(dag_id)
                .current_dir(&ctx_ref.root)
                .spawn()
                .map_err(|e| UiError::BadRequest(format!("dag spawn failed: {e}")))?;
        } else {
            return Err(UiError::BadRequest(format!(
                "ontoref binary not found at {}",
                ontoref_bin.display()
            )));
        }
    }

    if headers.contains_key("hx-request") {
        return Ok(Html(
            r#"<span class="badge badge-xs badge-success gap-1">✓ triggered</span>"#.to_string(),
        )
        .into_response());
    }
    Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response())
}

pub async fn emit_notification_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    _auth: AuthUser,
    Form(form): Form<EmitNotifForm>,
) -> Result<Response, UiError> {
    let registry = &state.registry;
    let target = registry.get(&form.target_slug).ok_or_else(|| {
        UiError::BadRequest(format!("unknown target project: {}", form.target_slug))
    })?;

    let payload = if form.payload.trim().is_empty() {
        None
    } else {
        serde_json::from_str::<serde_json::Value>(&form.payload).ok()
    };
    let actor = if form.source_actor.trim().is_empty() {
        None
    } else {
        Some(form.source_actor)
    };

    target.notifications.push_custom(
        &form.target_slug,
        &form.kind,
        &form.title,
        payload,
        actor,
        Some(slug.clone()),
    );

    Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response())
}

// ── Asset serving ────────────────────────────────────────────────────────────

/// Serve a file from `{project_root}/assets/` by slug + relative path.
/// Validates that the resolved path stays under the assets directory.
pub async fn serve_asset_mp(
    State(state): State<AppState>,
    Path((slug, asset_path)): Path<(String, String)>,
) -> Result<Response, UiError> {
    let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    serve_asset_from(&proj.root, &asset_path).await
}

pub async fn serve_asset_single(
    State(state): State<AppState>,
    Path(asset_path): Path<String>,
) -> Result<Response, UiError> {
    serve_asset_from(&state.project_root, &asset_path).await
}

async fn serve_asset_from(root: &std::path::Path, rel: &str) -> Result<Response, UiError> {
    let assets_root = root.join("assets");
    let Ok(canonical_root) = assets_root.canonicalize() else {
        return Err(UiError::NotConfigured);
    };

    // Resolve the requested path (tolerate leading `/`)
    let requested = canonical_root.join(rel.trim_start_matches('/'));
    let resolved = match requested.canonicalize() {
        Ok(p) => p,
        // Try appending index.html for bare directory URLs
        Err(_) => {
            let with_index = requested.join("index.html");
            with_index
                .canonicalize()
                .map_err(|_| UiError::NotConfigured)?
        }
    };

    // Security: must stay within assets root
    if !resolved.starts_with(&canonical_root) {
        return Ok(StatusCode::FORBIDDEN.into_response());
    }

    // Directory → serve index.html
    let file_path = if resolved.is_dir() {
        let idx = resolved.join("index.html");
        if idx.exists() {
            idx
        } else {
            return Ok(StatusCode::NOT_FOUND.into_response());
        }
    } else {
        resolved
    };

    let bytes = tokio::fs::read(&file_path)
        .await
        .map_err(|_| UiError::NotConfigured)?;

    let ct = asset_content_type(&file_path);
    let mut resp = bytes.into_response();
    resp.headers_mut()
        .insert(header::CONTENT_TYPE, HeaderValue::from_static(ct));
    Ok(resp)
}

fn asset_content_type(path: &std::path::Path) -> &'static str {
    match path.extension().and_then(|e| e.to_str()) {
        Some("html") | Some("htm") => "text/html; charset=utf-8",
        Some("svg") => "image/svg+xml",
        Some("css") => "text/css",
        Some("js") => "application/javascript",
        Some("json") => "application/json",
        Some("png") => "image/png",
        Some("jpg") | Some("jpeg") => "image/jpeg",
        Some("webp") => "image/webp",
        Some("woff2") => "font/woff2",
        Some("woff") => "font/woff",
        Some("md") => "text/plain; charset=utf-8",
        _ => "application/octet-stream",
    }
}

/// Detect known showcase pages in `assets/` (committed artefacts).
pub fn detect_showcase(root: &std::path::Path, base_url: &str) -> Vec<serde_json::Value> {
    let candidates = [
        (
            "branding",
            "Branding",
            &["branding/index.html", "index.html"] as &[&str],
        ),
        ("web", "Website", &["web/index.html"]),
        (
            "presentation",
            "Presentation",
            &[
                "presentation/dist/index.html",
                "presentation/slides/index.html",
            ],
        ),
    ];
    candidates
        .iter()
        .filter_map(|(id, label, paths)| {
            let found = paths
                .iter()
                .find(|p| root.join("assets").join(p).exists())?;
            Some(serde_json::json!({
                "id":    id,
                "label": label,
                "url":   format!("{base_url}/assets/{found}"),
            }))
        })
        .collect()
}

/// Scan `public/` for generated artefacts — any subdirectory that contains
/// an `index.html` becomes a served link.
///
/// Well-known subdirectory names get a display label; unknown names are
/// title-cased from the directory name.
pub fn detect_generated(root: &std::path::Path, base_url: &str) -> Vec<serde_json::Value> {
    let public_dir = root.join("public");
    let Ok(entries) = std::fs::read_dir(&public_dir) else {
        return vec![];
    };
    let known_labels: std::collections::HashMap<&str, &str> = [
        ("cargo-doc", "API Docs (cargo doc)"),
        ("docs", "Documentation"),
        ("mdbook", "MDBook"),
        ("book", "Book"),
        ("coverage", "Coverage Report"),
        ("typedoc", "TypeDoc"),
        ("storybook", "Storybook"),
    ]
    .into_iter()
    .collect();

    let mut items: Vec<serde_json::Value> = entries
        .flatten()
        .filter_map(|e| {
            let path = e.path();
            if !path.is_dir() {
                return None;
            }
            // Must have an index.html to be servable
            if !path.join("index.html").exists() {
                return None;
            }
            let dir_name = path.file_name()?.to_str()?.to_string();
            let label = known_labels
                .get(dir_name.as_str())
                .copied()
                .unwrap_or(&dir_name)
                .to_string();
            Some(serde_json::json!({
                "id":    dir_name,
                "label": label,
                "url":   format!("{base_url}/public/{dir_name}/"),
            }))
        })
        .collect();

    items.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
    items
}

/// Run `ontoref migrate pending --fmt json` for a given project root and return
/// the pending migration list. Returns empty on any error or timeout (2s).
pub async fn load_pending_migrations(project_root: &std::path::Path) -> Vec<serde_json::Value> {
    let Ok(output) = tokio::time::timeout(
        std::time::Duration::from_secs(2),
        tokio::process::Command::new("ontoref")
            .args(["migrate", "pending", "--fmt", "json"])
            .env("ONTOREF_ACTOR", "agent")
            .env("ONTOREF_PROJECT_ROOT", project_root)
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::null())
            .output(),
    )
    .await
    else {
        return vec![];
    };
    let Ok(output) = output else { return vec![] };
    if !output.status.success() {
        return vec![];
    }
    serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout).unwrap_or_default()
}

/// Serve a file from `{project_root}/public/` — generated, gitignored
/// artefacts.
pub async fn serve_public_mp(
    State(state): State<AppState>,
    Path((slug, pub_path)): Path<(String, String)>,
) -> Result<Response, UiError> {
    let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    serve_dir_from(&proj.root.join("public"), &pub_path).await
}

pub async fn serve_public_single(
    State(state): State<AppState>,
    Path(pub_path): Path<String>,
) -> Result<Response, UiError> {
    serve_dir_from(&state.project_root.join("public"), &pub_path).await
}

/// Read `api-catalog.json` from a consumer project root.
///
/// Convert api-catalog entries into Cytoscape graph nodes and edges.
///
/// Each catalog becomes a `Crate` node; each route becomes a `Route` node with
/// a `Contains` edge from its crate. Poles `"Code"` and `"Api"` are used so
/// the graph template can assign distinct colours without touching ontology
/// data.
fn api_catalog_graph_nodes(
    catalogs: &[(String, Vec<serde_json::Value>)],
) -> (Vec<serde_json::Value>, Vec<serde_json::Value>) {
    let mut nodes: Vec<serde_json::Value> = Vec::new();
    let mut edges: Vec<serde_json::Value> = Vec::new();

    for (crate_name, routes) in catalogs {
        let crate_id = format!("crate:{crate_name}");
        nodes.push(serde_json::json!({
            "id":            crate_id,
            "name":          crate_name,
            "level":         "Crate",
            "pole":          "Code",
            "description":   format!("API surface of {crate_name}"),
            "invariant":     false,
            "artifact_paths": [format!("artifacts/api-catalog-{crate_name}.ncl")],
            "adrs":          [],
        }));

        for route in routes {
            let method = route
                .get("method")
                .and_then(|v| v.as_str())
                .unwrap_or("GET");
            let path = route.get("path").and_then(|v| v.as_str()).unwrap_or("/");
            let desc = route
                .get("description")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let auth = route.get("auth").and_then(|v| v.as_str()).unwrap_or("none");
            let feature = route.get("feature").and_then(|v| v.as_str()).unwrap_or("");
            let source_file = route
                .get("source_file")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let actors = route
                .get("actors")
                .cloned()
                .unwrap_or(serde_json::json!([]));
            let tags = route.get("tags").cloned().unwrap_or(serde_json::json!([]));
            let params = route
                .get("params")
                .cloned()
                .unwrap_or(serde_json::json!([]));
            let route_id = format!(
                "route:{}:{}",
                method,
                path.replace('/', ":").trim_matches(':')
            );

            let mut artifact_paths = vec![format!("artifacts/api-catalog-{crate_name}.ncl")];
            if !source_file.is_empty() {
                artifact_paths.push(source_file.to_owned());
            }

            nodes.push(serde_json::json!({
                "id":             route_id,
                "name":           format!("{method} {path}"),
                "level":          "Route",
                "pole":           "Api",
                "description":    desc,
                "invariant":      false,
                "artifact_paths": artifact_paths,
                "adrs":           [],
                "method":         method,
                "auth":           auth,
                "actors":         actors,
                "tags":           tags,
                "params":         params,
                "feature":        feature,
            }));

            edges.push(serde_json::json!({
                "from": crate_id,
                "to":   route_id,
                "kind": "Contains",
                "note": "",
            }));
        }
    }

    (nodes, edges)
}

/// Scan `<project_root>/artifacts/` for all `api-catalog-*.ncl` files,
/// export each via the NCL cache, and return routes grouped by catalog name.
///
/// `api-catalog.ncl`          → name `"default"`
/// `api-catalog-foo-bar.ncl`  → name `"foo-bar"`
///
/// Results are sorted by name. Files that fail NCL export are skipped with a
/// warning; missing `artifacts/` directory is silently ignored.
async fn read_project_api_catalogs(
    project_root: &std::path::Path,
    cache: &std::sync::Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
) -> Vec<(String, Vec<serde_json::Value>)> {
    let rd = match std::fs::read_dir(project_root.join("artifacts")) {
        Ok(r) => r,
        Err(_) => return vec![],
    };

    let entries: Vec<_> = rd
        .filter_map(|e| e.ok())
        .filter_map(|e| {
            let raw = e.file_name();
            let fname = raw.to_string_lossy().into_owned();
            if !fname.starts_with("api-catalog") || !fname.ends_with(".ncl") {
                return None;
            }
            let name = if fname == "api-catalog.ncl" {
                "default".to_string()
            } else {
                fname
                    .strip_prefix("api-catalog-")?
                    .strip_suffix(".ncl")
                    .map(str::to_owned)?
            };
            Some((name, e.path()))
        })
        .collect();

    let mut catalogs: Vec<(String, Vec<serde_json::Value>)> = Vec::new();
    for (name, path) in entries {
        match cache.export(&path, import_path).await {
            Ok((value, _)) => {
                let routes = value
                    .get("routes")
                    .and_then(|v| v.as_array())
                    .cloned()
                    .unwrap_or_default();
                catalogs.push((name, routes));
            }
            Err(err) => {
                tracing::warn!(
                    "api-catalog NCL export failed for {}: {err}",
                    path.display()
                );
            }
        }
    }
    catalogs.sort_by(|a, b| a.0.cmp(&b.0));
    catalogs
}

/// Build graph nodes from `.ontology/manifest.ncl` — config sections and
/// requirements.
///
/// - `ConfigSection` nodes (pole `"Config"`) — one per
///   `config_surface.sections` entry: the settable targets and their
///   parameters.
/// - `Requirement` nodes (pole `"Env"`) — one per `requirements` entry: tools,
///   services, and infrastructure the project depends on externally.
///
/// No edges are synthesised here — both groups are independent clusters that
/// gain meaning from their labels and the filter toggle.
fn manifest_graph_nodes(
    manifest_json: &serde_json::Value,
) -> (Vec<serde_json::Value>, Vec<serde_json::Value>) {
    let mut nodes: Vec<serde_json::Value> = Vec::new();

    // Config sections.
    if let Some(sections) = manifest_json
        .get("config_surface")
        .and_then(|cs| cs.get("sections"))
        .and_then(|v| v.as_array())
    {
        for sec in sections {
            let id = sec.get("id").and_then(|v| v.as_str()).unwrap_or_default();
            if id.is_empty() {
                continue;
            }
            let desc = sec
                .get("description")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let contract = sec.get("contract").and_then(|v| v.as_str()).unwrap_or("");
            let label = if contract.is_empty() {
                id.to_string()
            } else {
                format!("{id} ({contract})")
            };
            nodes.push(serde_json::json!({
                "id":            format!("cfg:{id}"),
                "name":          label,
                "level":         "ConfigSection",
                "pole":          "Config",
                "description":   desc,
                "invariant":     false,
                "artifact_paths": [".ontoref/config.ncl"],
                "adrs":          [],
                "contract":      contract,
            }));
        }
    }

    // Requirements (external deps: tools, services, infra).
    if let Some(reqs) = manifest_json.get("requirements").and_then(|v| v.as_array()) {
        for req in reqs {
            let id = req.get("id").and_then(|v| v.as_str()).unwrap_or_default();
            if id.is_empty() {
                continue;
            }
            let name = req.get("name").and_then(|v| v.as_str()).unwrap_or(id);
            let kind = req.get("kind").and_then(|v| v.as_str()).unwrap_or("");
            let env = req.get("env").and_then(|v| v.as_str()).unwrap_or("");
            let required = req
                .get("required")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            let impact = req.get("impact").and_then(|v| v.as_str()).unwrap_or("");
            let provision = req.get("provision").and_then(|v| v.as_str()).unwrap_or("");
            let desc = format!(
                "[{kind}/{env}] {impact}{}",
                if provision.is_empty() {
                    String::new()
                } else {
                    format!(" — provision: {provision}")
                }
            );
            nodes.push(serde_json::json!({
                "id":            format!("req:{id}"),
                "name":          name,
                "level":         "Requirement",
                "pole":          "Env",
                "description":   desc,
                "invariant":     required,
                "artifact_paths": [".ontology/manifest.ncl"],
                "adrs":          [],
                "kind":          kind,
                "env":           env,
                "required":      required,
            }));
        }
    }

    (nodes, vec![])
}

/// Build graph nodes and edges from `.ontology/state.ncl` dimensions.
///
/// Each dimension becomes a `Dimension` node (pole `"State"`). Transitions are
/// inlined as data on the node. `coupled_with` entries produce `CoupledWith`
/// edges between dimension nodes. Missing or empty `states` arrays are ignored
/// — the current/desired state is carried as node fields for the tooltip.
fn state_graph_nodes(
    state_json: &serde_json::Value,
) -> (Vec<serde_json::Value>, Vec<serde_json::Value>) {
    let Some(dimensions) = state_json.get("dimensions").and_then(|v| v.as_array()) else {
        return (vec![], vec![]);
    };

    let mut nodes: Vec<serde_json::Value> = Vec::new();
    let mut edges: Vec<serde_json::Value> = Vec::new();

    for dim in dimensions {
        let id = dim.get("id").and_then(|v| v.as_str()).unwrap_or_default();
        if id.is_empty() {
            continue;
        }
        let node_id = format!("dim:{id}");
        let name = dim.get("name").and_then(|v| v.as_str()).unwrap_or(id);
        let desc = dim
            .get("description")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let current = dim
            .get("current_state")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let desired = dim
            .get("desired_state")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let horizon = dim.get("horizon").and_then(|v| v.as_str()).unwrap_or("");

        let reached_desired = !current.is_empty() && current == desired;
        nodes.push(serde_json::json!({
            "id":            node_id,
            "name":          name,
            "level":         "Dimension",
            "pole":          "State",
            "description":   format!("{desc} [current: {current} → desired: {desired}]"),
            "invariant":     reached_desired,
            "artifact_paths": [".ontology/state.ncl"],
            "adrs":          [],
            "current_state": current,
            "desired_state": desired,
            "horizon":       horizon,
        }));

        if let Some(coupled) = dim.get("coupled_with").and_then(|v| v.as_array()) {
            for c in coupled {
                if let Some(other_id) = c.as_str() {
                    edges.push(serde_json::json!({
                        "from": node_id,
                        "to":   format!("dim:{other_id}"),
                        "kind": "CoupledWith",
                        "note": "",
                    }));
                }
            }
        }
    }

    (nodes, edges)
}

/// Build graph nodes and edges from workspace `Cargo.toml` files.
///
/// Scans `<project_root>/crates/` for member crates. Each crate becomes a
/// `WorkspaceCrate` node (pole `"Code"`). Only inter-workspace `[dependencies]`
/// produce `DependsOn` edges — external crates are ignored.
fn workspace_crate_nodes(
    project_root: &std::path::Path,
) -> (Vec<serde_json::Value>, Vec<serde_json::Value>) {
    let crates_dir = project_root.join("crates");
    let Ok(rd) = std::fs::read_dir(&crates_dir) else {
        return (vec![], vec![]);
    };

    let mut crates: Vec<(String, std::path::PathBuf)> = rd
        .filter_map(|e| e.ok())
        .filter_map(|e| {
            let manifest_path = e.path().join("Cargo.toml");
            let content = std::fs::read_to_string(&manifest_path).ok()?;
            let table: toml::Table = toml::from_str(&content).ok()?;
            let name = table
                .get("package")
                .and_then(|p| p.get("name"))
                .and_then(|v| v.as_str())?
                .to_string();
            Some((name, e.path()))
        })
        .collect();
    crates.sort_by(|a, b| a.0.cmp(&b.0));

    let crate_names: std::collections::HashSet<String> =
        crates.iter().map(|(n, _)| n.clone()).collect();

    let mut nodes: Vec<serde_json::Value> = Vec::new();
    let mut edges: Vec<serde_json::Value> = Vec::new();

    for (name, path) in &crates {
        let node_id = format!("ws:{name}");
        let dir_name = path
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();

        let manifest_path = path.join("Cargo.toml");
        let (desc, ws_deps, crate_features) =
            if let Ok(content) = std::fs::read_to_string(&manifest_path) {
                let table: toml::Table = toml::from_str(&content).unwrap_or_default();
                let desc = table
                    .get("package")
                    .and_then(|p| p.get("description"))
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let deps: Vec<String> = table
                    .get("dependencies")
                    .and_then(|d| d.as_table())
                    .map(|t| {
                        t.keys()
                            .filter(|k| crate_names.contains(*k))
                            .cloned()
                            .collect()
                    })
                    .unwrap_or_default();
                let feats: Vec<String> = table
                    .get("features")
                    .and_then(|f| f.as_table())
                    .map(|t| t.keys().cloned().collect())
                    .unwrap_or_default();
                (desc, deps, feats)
            } else {
                (String::new(), vec![], vec![])
            };

        nodes.push(serde_json::json!({
            "id":            node_id,
            "name":          name,
            "level":         "WorkspaceCrate",
            "pole":          "Code",
            "description":   desc,
            "invariant":     false,
            "artifact_paths": [format!("crates/{dir_name}/Cargo.toml")],
            "adrs":          [],
            "features":      crate_features,
            "ws_deps":       ws_deps.iter().map(|d| format!("ws:{d}")).collect::<Vec<_>>(),
        }));

        for dep in ws_deps {
            edges.push(serde_json::json!({
                "from": node_id,
                "to":   format!("ws:{dep}"),
                "kind": "DependsOn",
                "note": "",
            }));
        }
    }

    (nodes, edges)
}

/// Generic file server for an arbitrary base directory.
async fn serve_dir_from(base: &std::path::Path, rel: &str) -> Result<Response, UiError> {
    let Ok(canonical_base) = base.canonicalize() else {
        return Ok(StatusCode::NOT_FOUND.into_response());
    };
    let requested = canonical_base.join(rel.trim_start_matches('/'));
    let resolved = match requested.canonicalize() {
        Ok(p) => p,
        Err(_) => {
            let with_index = requested.join("index.html");
            match with_index.canonicalize() {
                Ok(p) => p,
                Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()),
            }
        }
    };
    if !resolved.starts_with(&canonical_base) {
        return Ok(StatusCode::FORBIDDEN.into_response());
    }
    let file_path = if resolved.is_dir() {
        let idx = resolved.join("index.html");
        if idx.exists() {
            idx
        } else {
            return Ok(StatusCode::NOT_FOUND.into_response());
        }
    } else {
        resolved
    };
    let bytes = tokio::fs::read(&file_path)
        .await
        .map_err(|_| UiError::NotConfigured)?;
    let ct = asset_content_type(&file_path);
    let mut resp = bytes.into_response();
    resp.headers_mut()
        .insert(header::CONTENT_TYPE, HeaderValue::from_static(ct));
    Ok(resp)
}

// ── Backlog
// ───────────────────────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct BacklogStatusForm {
    pub id: String,
    pub status: String,
}

#[derive(Deserialize)]
pub struct BacklogAddForm {
    pub title: String,
    pub kind: String,
    pub priority: String,
    pub detail: String,
}

#[derive(Deserialize)]
pub struct BacklogEditForm {
    pub id: String,
    pub title: String,
    pub kind: String,
    pub priority: String,
    pub status: String,
    pub detail: String,
    /// Comma-separated ADR ids, e.g. "adr-001-title,adr-002-title"
    #[serde(default)]
    pub related_adrs: String,
    /// Comma-separated mode ids
    #[serde(default)]
    pub related_modes: String,
    /// Enum value: Adr | Mode | StateTransition | PrItem | "" (keep existing)
    #[serde(default)]
    pub graduates_to: String,
}

#[derive(Deserialize)]
pub struct BacklogDeleteForm {
    pub id: String,
}

#[derive(Deserialize)]
pub struct BacklogProposeForm {
    pub id: String,
    pub proposed_status: String,
    /// Optional actor label included in the notification title.
    #[serde(default)]
    pub proposed_by: String,
}

fn today_iso() -> String {
    // No chrono dep — derive from SystemTime epoch arithmetic.
    let secs = epoch_secs();
    let days_since_epoch = secs / 86400;
    // Gregorian calendar from Julian Day Number (JDN 2440588 = 1970-01-01)
    let jdn = days_since_epoch as i64 + 2440588;
    let (y, m, d) = jdn_to_ymd(jdn);
    format!("{y:04}-{m:02}-{d:02}")
}

fn jdn_to_ymd(jdn: i64) -> (i64, i64, i64) {
    let a = jdn + 32044;
    let b = (4 * a + 3) / 146097;
    let c = a - (146097 * b) / 4;
    let dd = (4 * c + 3) / 1461;
    let e = c - (1461 * dd) / 4;
    let mm = (5 * e + 2) / 153;
    let d = e - (153 * mm + 2) / 5 + 1;
    let m = mm + 3 - 12 * (mm / 10);
    let y = 100 * b + dd - 4800 + mm / 10;
    (y, m, d)
}

#[derive(Serialize)]
struct BacklogStats {
    total: usize,
    open: usize,
    inprog: usize,
    done: usize,
    cancelled: usize,
    critical: usize,
}

async fn load_backlog_items(
    cache: &Arc<crate::cache::NclCache>,
    backlog_path: &std::path::Path,
    import_path: Option<&str>,
) -> Vec<serde_json::Value> {
    let Ok((json, _)) = cache.export(backlog_path, import_path).await else {
        return vec![];
    };
    json.get("items")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default()
}

fn backlog_stats(items: &[serde_json::Value]) -> BacklogStats {
    let count = |key: &str, val: &str| {
        items
            .iter()
            .filter(|it| it.get(key).and_then(|v| v.as_str()) == Some(val))
            .count()
    };
    BacklogStats {
        total: items.len(),
        open: count("status", "Open"),
        inprog: count("status", "InProgress"),
        done: count("status", "Done"),
        cancelled: count("status", "Cancelled"),
        critical: count("priority", "Critical"),
    }
}

pub async fn backlog_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let backlog_path = state.project_root.join("reflection").join("backlog.ncl");
    let items = if backlog_path.exists() {
        load_backlog_items(
            &state.cache,
            &backlog_path,
            state.nickel_import_path.as_deref(),
        )
        .await
    } else {
        vec![]
    };
    let stats = backlog_stats(&items);
    let mut ctx = Context::new();
    ctx.insert("items", &items);
    ctx.insert("stats", &stats);
    ctx.insert("has_backlog", &backlog_path.exists());
    ctx.insert("base_url", "/ui");
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;
    render(tera, "pages/backlog.html", &ctx).await
}

pub async fn backlog_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let backlog_path = proj.root.join("reflection").join("backlog.ncl");
    let items = if backlog_path.exists() {
        load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await
    } else {
        vec![]
    };
    proj.cache
        .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone()));
    let stats = backlog_stats(&items);
    let base_url = format!("/ui/{slug}");
    let mut ctx = Context::new();
    ctx.insert("items", &items);
    ctx.insert("stats", &stats);
    ctx.insert("has_backlog", &backlog_path.exists());
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &auth_role_str(&auth));
    insert_brand_ctx(
        &mut ctx,
        &proj.root,
        &proj.cache,
        proj.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;
    render(tera, "pages/backlog.html", &ctx).await
}

pub async fn backlog_update_status(
    State(state): State<AppState>,
    Form(form): Form<BacklogStatusForm>,
) -> Result<Response, UiError> {
    let backlog_path = state.project_root.join("reflection").join("backlog.ncl");
    let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
    super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso())
        .map_err(|e| UiError::NclExport {
            path: "backlog.ncl".into(),
            reason: e.to_string(),
        })?;
    state
        .cache
        .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path));
    Ok(Redirect::to("/ui/backlog").into_response())
}

pub async fn backlog_update_status_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
    headers: axum::http::HeaderMap,
    Form(form): Form<BacklogStatusForm>,
) -> Result<Response, UiError> {
    if auth_role_str(&auth) != "admin" {
        return Err(UiError::Forbidden(
            "admin role required for backlog writes".into(),
        ));
    }
    let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let backlog_path = proj.root.join("reflection").join("backlog.ncl");
    let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
    super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso())
        .map_err(|e| UiError::NclExport {
            path: "backlog.ncl".into(),
            reason: e.to_string(),
        })?;
    proj.cache
        .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone()));

    if headers.contains_key("hx-request") {
        let tera = tera_ref(&state)?;
        let items =
            load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await;
        let mut ctx = Context::new();
        ctx.insert("items", &items);
        ctx.insert("base_url", &format!("/ui/{slug}"));
        return render(tera, "partials/backlog_items_container.html", &ctx)
            .await
            .map(IntoResponse::into_response);
    }
    Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response())
}

pub async fn backlog_add(
    State(state): State<AppState>,
    Form(form): Form<BacklogAddForm>,
) -> Result<Response, UiError> {
    let backlog_path = state.project_root.join("reflection").join("backlog.ncl");
    let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
    super::backlog_ncl::add_item(
        &backlog_path,
        &form.title,
        &form.kind,
        &form.priority,
        &form.detail,
        &today_iso(),
    )
    .map_err(|e| UiError::NclExport {
        path: "backlog.ncl".into(),
        reason: e.to_string(),
    })?;
    state
        .cache
        .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path));
    Ok(Redirect::to("/ui/backlog").into_response())
}

pub async fn backlog_add_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
    headers: axum::http::HeaderMap,
    Form(form): Form<BacklogAddForm>,
) -> Result<Response, UiError> {
    if auth_role_str(&auth) != "admin" {
        return Err(UiError::Forbidden(
            "admin role required for backlog writes".into(),
        ));
    }
    let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let backlog_path = proj.root.join("reflection").join("backlog.ncl");
    let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
    super::backlog_ncl::add_item(
        &backlog_path,
        &form.title,
        &form.kind,
        &form.priority,
        &form.detail,
        &today_iso(),
    )
    .map_err(|e| UiError::NclExport {
        path: "backlog.ncl".into(),
        reason: e.to_string(),
    })?;
    proj.cache
        .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone()));

    if headers.contains_key("hx-request") {
        let tera = tera_ref(&state)?;
        let items =
            load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await;
        let mut ctx = Context::new();
        ctx.insert("items", &items);
        ctx.insert("base_url", &format!("/ui/{slug}"));
        return render(tera, "partials/backlog_items_container.html", &ctx)
            .await
            .map(IntoResponse::into_response);
    }
    Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response())
}

pub async fn backlog_edit_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
    headers: axum::http::HeaderMap,
    Form(form): Form<BacklogEditForm>,
) -> Result<Response, UiError> {
    if auth_role_str(&auth) != "admin" {
        return Err(UiError::Forbidden(
            "admin role required for backlog writes".into(),
        ));
    }
    let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let backlog_path = proj.root.join("reflection").join("backlog.ncl");
    let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
    super::backlog_ncl::update_item(
        &backlog_path,
        &form.id,
        &super::backlog_ncl::ItemUpdate {
            title: &form.title,
            kind: &form.kind,
            priority: &form.priority,
            status: &form.status,
            detail: &form.detail,
            related_adrs: &form.related_adrs,
            related_modes: &form.related_modes,
            graduates_to: &form.graduates_to,
            today: &today_iso(),
        },
    )
    .map_err(|e| UiError::NclExport {
        path: "backlog.ncl".into(),
        reason: e.to_string(),
    })?;
    proj.cache
        .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone()));

    if headers.contains_key("hx-request") {
        let tera = tera_ref(&state)?;
        let items =
            load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await;
        let mut ctx = Context::new();
        ctx.insert("items", &items);
        ctx.insert("base_url", &format!("/ui/{slug}"));
        return render(tera, "partials/backlog_items_container.html", &ctx)
            .await
            .map(IntoResponse::into_response);
    }
    Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response())
}

pub async fn backlog_delete_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
    headers: axum::http::HeaderMap,
    Form(form): Form<BacklogDeleteForm>,
) -> Result<Response, UiError> {
    if auth_role_str(&auth) != "admin" {
        return Err(UiError::Forbidden(
            "admin role required for backlog writes".into(),
        ));
    }
    let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let backlog_path = proj.root.join("reflection").join("backlog.ncl");
    let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
    super::backlog_ncl::delete_item(&backlog_path, &form.id).map_err(|e| UiError::NclExport {
        path: "backlog.ncl".into(),
        reason: e.to_string(),
    })?;
    proj.cache
        .invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone()));

    if headers.contains_key("hx-request") {
        let tera = tera_ref(&state)?;
        let items =
            load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await;
        let mut ctx = Context::new();
        ctx.insert("items", &items);
        ctx.insert("base_url", &format!("/ui/{slug}"));
        return render(tera, "partials/backlog_items_container.html", &ctx)
            .await
            .map(IntoResponse::into_response);
    }
    Ok(Redirect::to(&format!("/ui/{slug}/backlog")).into_response())
}

pub async fn backlog_propose_status_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    _auth: AuthUser,
    headers: axum::http::HeaderMap,
    Form(form): Form<BacklogProposeForm>,
) -> Result<Response, UiError> {
    let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    // Resolve item title for the notification title.
    let backlog_path = proj.root.join("reflection").join("backlog.ncl");
    let items = load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await;
    let item_title = items
        .iter()
        .find(|it| it.get("id").and_then(|v| v.as_str()) == Some(form.id.as_str()))
        .and_then(|it| it.get("title").and_then(|v| v.as_str()))
        .unwrap_or(&form.id)
        .to_string();
    let current_status = items
        .iter()
        .find(|it| it.get("id").and_then(|v| v.as_str()) == Some(form.id.as_str()))
        .and_then(|it| it.get("status").and_then(|v| v.as_str()))
        .unwrap_or("Unknown")
        .to_string();

    let by = if form.proposed_by.is_empty() {
        "actor".to_string()
    } else {
        form.proposed_by.clone()
    };

    let payload = serde_json::json!({
        "item_id":         form.id,
        "item_title":      item_title,
        "current_status":  current_status,
        "proposed_status": form.proposed_status,
        "proposed_by":     by,
        "actions": [
            { "id": "approve", "label": format!("Approve → {}", form.proposed_status), "mode": "backlog_approve" },
            { "id": "reject",  "label": "Reject",  "mode": "backlog_reject" }
        ]
    });

    proj.notifications.push_custom(
        &slug,
        "backlog_review",
        format!("{}: {} → {}", form.id, item_title, form.proposed_status),
        Some(payload),
        Some(form.proposed_by),
        None,
    );

    if headers.contains_key("hx-request") {
        return Ok(Html(
            r#"<span class="badge badge-xs badge-warning gap-1">⏳ proposed</span>"#.to_string(),
        )
        .into_response());
    }
    Ok(Redirect::to(&format!("/ui/{slug}/notifications")).into_response())
}

// ── Manage ────────────────────────────────────────────────────────────────────

/// Returns `~/.config/ontoref/projects.ncl` path.
fn projects_ncl_path() -> std::path::PathBuf {
    std::env::var_os("HOME")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|| std::path::PathBuf::from("."))
        .join(".config")
        .join("ontoref")
        .join("projects.ncl")
}

/// Read `projects.ncl` and return the set of import paths already registered.
fn read_registered_paths(projects_file: &std::path::Path) -> Vec<String> {
    let Ok(content) = std::fs::read_to_string(projects_file) else {
        return vec![];
    };
    content
        .lines()
        .filter_map(|l| {
            let l = l.trim();
            if l.starts_with("import ") {
                let inner = l.trim_start_matches("import").trim();
                let inner = inner.trim_matches('"');
                Some(inner.to_string())
            } else {
                None
            }
        })
        .collect()
}

/// Append a project.ncl import to `projects.ncl`, creating the file if needed.
fn persist_project_add(
    project_root: &std::path::Path,
    projects_file: &std::path::Path,
) -> std::io::Result<()> {
    let ncl_path = project_root.join(".ontoref").join("project.ncl");
    let ncl_str = ncl_path.to_string_lossy().to_string();

    let existing = read_registered_paths(projects_file);
    if existing.iter().any(|p| p == &ncl_str) {
        return Ok(());
    }

    let mut all = existing;
    all.push(ncl_str);
    write_projects_ncl(projects_file, &all)
}

/// Remove a project.ncl import from `projects.ncl`.
fn persist_project_remove(
    project_root: &std::path::Path,
    projects_file: &std::path::Path,
) -> std::io::Result<()> {
    let ncl_path = project_root.join(".ontoref").join("project.ncl");
    let ncl_str = ncl_path.to_string_lossy().to_string();
    let remaining: Vec<String> = read_registered_paths(projects_file)
        .into_iter()
        .filter(|p| p != &ncl_str)
        .collect();
    write_projects_ncl(projects_file, &remaining)
}

fn write_projects_ncl(projects_file: &std::path::Path, paths: &[String]) -> std::io::Result<()> {
    if let Some(parent) = projects_file.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let content = if paths.is_empty() {
        "# AUTO-GENERATED by ontoref. Do not edit by hand.\n# Add a project: ontoref project-add \
         /path/to/project\n\n[]\n"
            .to_string()
    } else {
        let imports = paths
            .iter()
            .map(|p| format!("    [import \"{p}\"],"))
            .collect::<Vec<_>>()
            .join("\n");
        format!(
            "# AUTO-GENERATED by ontoref. Do not edit by hand.\n# Add a project: ontoref \
             project-add /path/to/project\n\nstd.array.flatten [\n{imports}\n]\n"
        )
    };
    std::fs::write(projects_file, content)
}

#[derive(Deserialize)]
pub struct AddProjectForm {
    pub root: String,
}

#[derive(Deserialize)]
pub struct RemoveProjectForm {
    pub slug: String,
}

/// Guarded variant: requires `AdminGuard` (any admin session or no auth
/// configured).
pub async fn manage_page_guarded(
    State(state): State<AppState>,
    _guard: super::auth::AdminGuard,
) -> Result<Html<String>, UiError> {
    manage_page(State(state)).await
}

pub async fn manage_add_guarded(
    State(state): State<AppState>,
    _guard: super::auth::AdminGuard,
    headers: axum::http::HeaderMap,
    Form(form): Form<AddProjectForm>,
) -> Result<Response, UiError> {
    if !headers.contains_key("hx-request") {
        return manage_add(State(state), Form(form)).await;
    }
    let tera = tera_ref(&state)?;
    let projects_file = projects_ncl_path();
    let root = std::path::PathBuf::from(form.root.trim());
    let project_ncl = root.join(".ontoref").join("project.ncl");
    let entry: crate::registry::RegistryEntry = if project_ncl.exists() {
        match state
            .cache
            .export(&project_ncl, state.nickel_import_path.as_deref())
            .await
        {
            Ok((json, _)) => {
                serde_json::from_value(json).unwrap_or_else(|_| crate::registry::RegistryEntry {
                    slug: root
                        .file_name()
                        .and_then(|n| n.to_str())
                        .unwrap_or("unknown")
                        .to_string(),
                    root: root.clone(),
                    nickel_import_paths: vec![],
                    keys: vec![],
                    remote_url: String::new(),
                    push_only: false,
                })
            }
            Err(_) => crate::registry::RegistryEntry {
                slug: root
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("unknown")
                    .to_string(),
                root: root.clone(),
                nickel_import_paths: vec![],
                keys: vec![],
                remote_url: String::new(),
                push_only: false,
            },
        }
    } else {
        crate::registry::RegistryEntry {
            slug: root
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("unknown")
                .to_string(),
            root: root.clone(),
            nickel_import_paths: vec![],
            keys: vec![],
            remote_url: String::new(),
            push_only: false,
        }
    };
    let manage_error = registry_add_and_persist(&state, entry, &projects_file)
        .await
        .err()
        .map(|e| e.to_string());
    let registered = read_registered_paths(&projects_file);
    let projects: Vec<serde_json::Value> = state
        .registry
        .all()
        .into_iter()
        .map(|ctx| manage_project_json(&ctx, &registered))
        .collect();
    let mut ctx = Context::new();
    ctx.insert("projects", &projects);
    ctx.insert("manage_error", &manage_error);
    render(tera, "partials/manage_projects_section.html", &ctx)
        .await
        .map(IntoResponse::into_response)
}

pub async fn manage_remove_guarded(
    State(state): State<AppState>,
    _guard: super::auth::AdminGuard,
    headers: axum::http::HeaderMap,
    Form(form): Form<RemoveProjectForm>,
) -> Result<Response, UiError> {
    if !headers.contains_key("hx-request") {
        return manage_remove(State(state), Form(form)).await;
    }
    let registry = &state.registry;
    let root = registry.get(&form.slug).map(|ctx| ctx.root.clone());
    registry.remove_project(&form.slug);
    if let Some(root) = root {
        let projects_file = projects_ncl_path();
        if let Err(e) = persist_project_remove(&root, &projects_file) {
            warn!(slug = %form.slug, error = %e, "removed from registry but failed to persist removal");
        }
    }
    let tera = tera_ref(&state)?;
    let projects_file = projects_ncl_path();
    let registered = read_registered_paths(&projects_file);
    let projects: Vec<serde_json::Value> = state
        .registry
        .all()
        .into_iter()
        .map(|ctx| manage_project_json(&ctx, &registered))
        .collect();
    let mut ctx = Context::new();
    ctx.insert("projects", &projects);
    ctx.insert("manage_error", &Option::<String>::None);
    render(tera, "partials/manage_projects_section.html", &ctx)
        .await
        .map(IntoResponse::into_response)
}

fn manage_project_json(
    ctx: &crate::registry::ProjectContext,
    registered_paths: &[String],
) -> serde_json::Value {
    let key_count = ctx.keys.read().map(|k| k.len()).unwrap_or(0);
    let roles: Vec<String> = ctx
        .keys
        .read()
        .map(|keys| {
            keys.iter()
                .map(|k| format!("{:?}", k.role).to_lowercase())
                .collect()
        })
        .unwrap_or_default();
    let ncl_path = ctx.root.join(".ontoref").join("project.ncl");
    let in_projects_ncl = registered_paths.contains(&ncl_path.to_string_lossy().to_string());
    let opmode = if ctx.push_only { "push" } else { "daemon" };
    serde_json::json!({
        "slug":            ctx.slug,
        "root":            ctx.root.display().to_string(),
        "auth":            ctx.auth_enabled(),
        "key_count":       key_count,
        "roles":           roles,
        "push_only":       ctx.push_only,
        "opmode":          opmode,
        "in_projects_ncl": in_projects_ncl,
    })
}

pub async fn manage_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let projects_file = projects_ncl_path();
    let registered = read_registered_paths(&projects_file);

    let projects: Vec<serde_json::Value> = state
        .registry
        .all()
        .into_iter()
        .map(|ctx| manage_project_json(&ctx, &registered))
        .collect();

    let mut ctx = Context::new();
    ctx.insert("projects", &projects);
    ctx.insert("base_url", "/ui");
    ctx.insert("hide_project_nav", &true);
    ctx.insert("error", &Option::<String>::None);
    ctx.insert("daemon_admin_enabled", &state.daemon_admin_hash.is_some());
    insert_mcp_ctx(&mut ctx);
    render(tera, "pages/manage.html", &ctx).await
}

pub async fn manage_logout(
    State(state): State<AppState>,
    request: axum::extract::Request,
) -> Response {
    use axum::http::header;

    use crate::session::extract_cookie;
    let cookie_str = request
        .headers()
        .get(header::COOKIE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    if let Some(token) = extract_cookie(cookie_str, crate::session::COOKIE_NAME) {
        state.sessions.revoke(&token);
    }
    let clear = format!(
        "{}=; Path=/ui/; HttpOnly; SameSite=Strict; Max-Age=0",
        crate::session::COOKIE_NAME
    );
    (
        [(header::SET_COOKIE, clear)],
        Redirect::to("/ui/manage/login"),
    )
        .into_response()
}

pub async fn manage_add(
    State(state): State<AppState>,
    Form(form): Form<AddProjectForm>,
) -> Result<Response, UiError> {
    let root = std::path::PathBuf::from(form.root.trim());
    let project_ncl = root.join(".ontoref").join("project.ncl");

    // Load the project.ncl via nickel to get slug + import_paths + keys.
    let entry: crate::registry::RegistryEntry = if project_ncl.exists() {
        match state
            .cache
            .export(&project_ncl, state.nickel_import_path.as_deref())
            .await
        {
            Ok((json, _)) => {
                serde_json::from_value(json).unwrap_or_else(|_| crate::registry::RegistryEntry {
                    slug: root
                        .file_name()
                        .and_then(|n| n.to_str())
                        .unwrap_or("unknown")
                        .to_string(),
                    root: root.clone(),
                    nickel_import_paths: vec![],
                    keys: vec![],
                    remote_url: String::new(),
                    push_only: false,
                })
            }
            Err(_) => crate::registry::RegistryEntry {
                slug: root
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("unknown")
                    .to_string(),
                root: root.clone(),
                nickel_import_paths: vec![],
                keys: vec![],
                remote_url: String::new(),
                push_only: false,
            },
        }
    } else {
        crate::registry::RegistryEntry {
            slug: root
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("unknown")
                .to_string(),
            root: root.clone(),
            nickel_import_paths: vec![],
            keys: vec![],
            remote_url: String::new(),
            push_only: false,
        }
    };

    let projects_file = projects_ncl_path();
    if let Err(e) = registry_add_and_persist(&state, entry, &projects_file).await {
        let tera = tera_ref(&state)?;
        let registered = read_registered_paths(&projects_file);
        let projects: Vec<serde_json::Value> = state
            .registry
            .all()
            .into_iter()
            .map(|ctx| manage_project_json(&ctx, &registered))
            .collect();
        let mut ctx = Context::new();
        ctx.insert("projects", &projects);
        ctx.insert("base_url", "/ui");
        ctx.insert("error", &e.to_string());
        ctx.insert("daemon_admin_enabled", &state.daemon_admin_hash.is_some());
        return render(tera, "pages/manage.html", &ctx)
            .await
            .map(IntoResponse::into_response);
    }

    Ok(Redirect::to("/ui/manage").into_response())
}

async fn registry_add_and_persist(
    state: &AppState,
    entry: crate::registry::RegistryEntry,
    projects_file: &std::path::Path,
) -> anyhow::Result<()> {
    let root = entry.root.clone();
    state.registry.add_project(entry)?;
    persist_project_add(&root, projects_file)
        .map_err(|e| anyhow::anyhow!("added to registry but failed to persist: {e}"))?;
    Ok(())
}

pub async fn manage_remove(
    State(state): State<AppState>,
    Form(form): Form<RemoveProjectForm>,
) -> Result<Response, UiError> {
    let registry = &state.registry;
    let root = registry.get(&form.slug).map(|ctx| ctx.root.clone());
    registry.remove_project(&form.slug);
    if let Some(root) = root {
        let projects_file = projects_ncl_path();
        if let Err(e) = persist_project_remove(&root, &projects_file) {
            warn!(slug = %form.slug, error = %e, "removed from registry but failed to persist removal");
        }
    }
    Ok(Redirect::to("/ui/manage").into_response())
}

pub async fn modes_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let modes_dir = ctx_ref.root.join("reflection").join("modes");
    let mut mode_entries: Vec<serde_json::Value> = Vec::new();

    if let Ok(entries) = std::fs::read_dir(&modes_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let rel = path
                .strip_prefix(&ctx_ref.root)
                .unwrap_or(&path)
                .to_string_lossy()
                .to_string();

            match ctx_ref
                .cache
                .export(&path, ctx_ref.import_path.as_deref())
                .await
            {
                Ok((mut json, _)) => {
                    json.as_object_mut()
                        .map(|o| o.insert("_file".to_string(), serde_json::Value::String(rel)));
                    mode_entries.push(json);
                }
                Err(e) => {
                    warn!(path = %rel, error = %e, "failed to export reflection mode");
                    mode_entries.push(serde_json::json!({
                        "_file": rel,
                        "id": path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"),
                        "_error": e.to_string(),
                    }));
                }
            }
        }
    }

    mode_entries.sort_by_key(|v| {
        v.get("id")
            .and_then(|i| i.as_str())
            .unwrap_or("")
            .to_string()
    });

    let base_url = format!("/ui/{slug}");
    let showcase = detect_showcase(&ctx_ref.root, &base_url);
    let generated = detect_generated(&ctx_ref.root, &base_url);

    let mut ctx = Context::new();
    ctx.insert("modes", &mode_entries);
    ctx.insert("total", &mode_entries.len());
    ctx.insert("showcase", &showcase);
    ctx.insert("generated", &generated);
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &auth_role_str(&auth));
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/modes.html", &ctx).await
}

// ── Agent task composer
// ───────────────────────────────────────────────────────

/// Default LLM providers used when config.ncl has no `providers` array.
fn default_providers() -> Vec<serde_json::Value> {
    vec![
        serde_json::json!({ "id": "anthropic", "label": "Anthropic (Claude)", "api_url": "", "model": "claude-opus-4-6", "key_env": "ANTHROPIC_API_KEY", "kind": "anthropic" }),
        serde_json::json!({ "id": "openai",    "label": "OpenAI (GPT-4o)",     "api_url": "", "model": "gpt-4o",           "key_env": "OPENAI_API_KEY",     "kind": "openai"    }),
        serde_json::json!({ "id": "local",     "label": "Local (Ollama)",       "api_url": "http://localhost:11434/v1/chat/completions", "model": "llama3.2", "key_env": "", "kind": "openai" }),
    ]
}

async fn read_providers(
    root: &std::path::Path,
    cache: &Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
) -> Vec<serde_json::Value> {
    let config_path = root.join(".ontoref").join("config.ncl");
    if config_path.exists() {
        if let Ok((json, _)) = cache.export(&config_path, import_path).await {
            if let Some(arr) = json.get("providers").and_then(|v| v.as_array()) {
                if !arr.is_empty() {
                    return arr.clone();
                }
            }
        }
    }
    default_providers()
}

pub async fn compose_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let forms_dir = ctx_ref.root.join("reflection").join("forms");
    let mut forms: Vec<serde_json::Value> = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&forms_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let stem = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            forms.push(serde_json::json!({
                "id":    stem,
                "label": stem.replace('_', " "),
            }));
        }
    }
    forms.sort_by_key(|v| {
        v.get("id")
            .and_then(|s| s.as_str())
            .unwrap_or("")
            .to_string()
    });

    let providers = read_providers(
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
    )
    .await;

    let base_url = format!("/ui/{slug}");
    let mut ctx = Context::new();
    ctx.insert("forms", &forms);
    ctx.insert("providers", &providers);
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &auth_role_str(&auth));
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/compose.html", &ctx).await
}

pub async fn compose_form_schema_mp(
    State(state): State<AppState>,
    Path((slug, form_id)): Path<(String, String)>,
    _auth: AuthUser,
) -> Result<axum::Json<serde_json::Value>, UiError> {
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let form_path = ctx_ref
        .root
        .join("reflection")
        .join("forms")
        .join(format!("{form_id}.ncl"));

    if !form_path.exists() {
        return Err(UiError::BadRequest(format!("form not found: {form_id}")));
    }

    let (json, _) = ctx_ref
        .cache
        .export(&form_path, ctx_ref.import_path.as_deref())
        .await
        .map_err(|e| UiError::NclExport {
            path: form_id.clone(),
            reason: e.to_string(),
        })?;

    Ok(axum::Json(json))
}

#[derive(serde::Deserialize)]
pub struct ComposeSendBody {
    pub provider_id: String,
    pub prompt: String,
    #[serde(default)]
    pub system: String,
}

pub async fn compose_send_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    _auth: AuthUser,
    axum::Json(body): axum::Json<ComposeSendBody>,
) -> Result<axum::Json<serde_json::Value>, UiError> {
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;

    let providers = read_providers(
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
    )
    .await;

    let provider = providers
        .iter()
        .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(body.provider_id.as_str()))
        .ok_or_else(|| UiError::BadRequest(format!("unknown provider: {}", body.provider_id)))?
        .clone();

    let api_url = provider
        .get("api_url")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let model = provider
        .get("model")
        .and_then(|v| v.as_str())
        .unwrap_or("claude-opus-4-6")
        .to_string();
    let key_env = provider
        .get("key_env")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let kind = provider
        .get("kind")
        .and_then(|v| v.as_str())
        .unwrap_or("anthropic")
        .to_string();

    let api_key = if key_env.is_empty() {
        String::new()
    } else {
        std::env::var(&key_env).unwrap_or_default()
    };

    if api_key.is_empty() && !matches!(kind.as_str(), "openai") {
        return Err(UiError::BadRequest(format!(
            "API key not configured: set {} environment variable",
            key_env
        )));
    }

    let result = match kind.as_str() {
        "openai" => call_openai_api(&api_url, &api_key, &model, &body.system, &body.prompt).await,
        _ => call_anthropic_api(&api_url, &api_key, &model, &body.system, &body.prompt).await,
    };

    result
        .map(axum::Json)
        .map_err(|e| UiError::BadRequest(e.to_string()))
}

async fn call_anthropic_api(
    url: &str,
    key: &str,
    model: &str,
    system: &str,
    prompt: &str,
) -> anyhow::Result<serde_json::Value> {
    let effective_url = if url.is_empty() {
        "https://api.anthropic.com/v1/messages"
    } else {
        url
    };
    let mut req = serde_json::json!({
        "model": model,
        "max_tokens": 4096,
        "messages": [{"role": "user", "content": prompt}]
    });
    if !system.is_empty() {
        req["system"] = serde_json::Value::String(system.to_string());
    }
    let resp = reqwest::Client::new()
        .post(effective_url)
        .header("x-api-key", key)
        .header("anthropic-version", "2023-06-01")
        .json(&req)
        .send()
        .await?
        .json::<serde_json::Value>()
        .await?;
    Ok(resp)
}

async fn call_openai_api(
    url: &str,
    key: &str,
    model: &str,
    system: &str,
    prompt: &str,
) -> anyhow::Result<serde_json::Value> {
    let effective_url = if url.is_empty() {
        "https://api.openai.com/v1/chat/completions"
    } else {
        url
    };
    let mut messages: Vec<serde_json::Value> = Vec::new();
    if !system.is_empty() {
        messages.push(serde_json::json!({"role": "system", "content": system}));
    }
    messages.push(serde_json::json!({"role": "user", "content": prompt}));
    let resp = reqwest::Client::new()
        .post(effective_url)
        .header("Authorization", format!("Bearer {key}"))
        .json(&serde_json::json!({ "model": model, "messages": messages }))
        .send()
        .await?
        .json::<serde_json::Value>()
        .await?;
    Ok(resp)
}

// ── Actions
// ───────────────────────────────────────────────────────────────────

/// Load `quick_actions` from `.ontoref/config.ncl`.
async fn load_quick_actions(
    cache: &Arc<crate::cache::NclCache>,
    root: &std::path::Path,
    import_path: Option<&str>,
) -> Vec<serde_json::Value> {
    let config_path = root.join(".ontoref").join("config.ncl");
    if !config_path.exists() {
        return vec![];
    }
    let Ok((json, _)) = cache.export(&config_path, import_path).await else {
        return vec![];
    };
    json.get("quick_actions")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default()
}

pub async fn actions_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let actions = load_quick_actions(
        &state.cache,
        &state.project_root,
        state.nickel_import_path.as_deref(),
    )
    .await;
    let mut ctx = Context::new();
    ctx.insert("actions", &actions);
    ctx.insert("base_url", "/ui");
    ctx.insert("slug", &Option::<String>::None);
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;
    render(tera, "pages/actions.html", &ctx).await
}

pub async fn actions_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let actions = load_quick_actions(
        &ctx_ref.cache,
        &ctx_ref.root,
        ctx_ref.import_path.as_deref(),
    )
    .await;
    let base_url = format!("/ui/{slug}");
    let mut ctx = Context::new();
    ctx.insert("actions", &actions);
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &auth_role_str(&auth));
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;
    render(tera, "pages/actions.html", &ctx).await
}

// ── Q&A ───────────────────────────────────────────────────────────────────────

pub async fn qa_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let mut ctx = Context::new();
    ctx.insert("base_url", "/ui");
    ctx.insert("slug", &Option::<String>::None);
    let entries = load_qa_entries(
        &state.cache,
        &state.project_root,
        state.nickel_import_path.as_deref(),
    )
    .await;
    ctx.insert("entries", &entries);
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
    )
    .await;
    render(tera, "pages/qa.html", &ctx).await
}

pub async fn qa_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let base_url = format!("/ui/{slug}");
    let entries = load_qa_entries(
        &ctx_ref.cache,
        &ctx_ref.root,
        ctx_ref.import_path.as_deref(),
    )
    .await;
    let mut ctx = Context::new();
    ctx.insert("base_url", &base_url);
    ctx.insert("slug", &slug);
    ctx.insert("current_role", &auth_role_str(&auth));
    ctx.insert("entries", &entries);
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;
    render(tera, "pages/qa.html", &ctx).await
}

// ── Q&A mutation ─────────────────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct QaAddRequest {
    pub question: String,
    pub answer: Option<String>,
    pub actor: Option<String>,
    pub tags: Option<Vec<String>>,
    pub related: Option<Vec<String>>,
    pub slug: Option<String>,
}

pub async fn qa_add(
    State(state): State<AppState>,
    Json(body): Json<QaAddRequest>,
) -> impl IntoResponse {
    let (root, cache) = if let Some(slug) = body.slug.as_deref() {
        if let Some(ctx) = state.registry.get(slug) {
            (ctx.root.clone(), ctx.cache.clone())
        } else {
            (state.project_root.clone(), state.cache.clone())
        }
    } else {
        (state.project_root.clone(), state.cache.clone())
    };

    let qa_path = root.join("reflection").join("qa.ncl");
    let _guard = state.ncl_write_lock.acquire(&qa_path).await;
    if !qa_path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(
                serde_json::json!({ "error": "qa.ncl not found — create reflection/qa.ncl first" }),
            ),
        );
    }

    let answer = body.answer.as_deref().unwrap_or("");
    let actor = body.actor.as_deref().unwrap_or("human");
    let tags = body.tags.as_deref().unwrap_or(&[]);
    let related = body.related.as_deref().unwrap_or(&[]);
    let now = now_iso();

    match super::qa_ncl::add_entry(&qa_path, &body.question, answer, actor, &now, tags, related) {
        Ok(id) => {
            cache.invalidate_file(&qa_path);
            (
                StatusCode::OK,
                Json(serde_json::json!({
                    "ok":         true,
                    "id":         id,
                    "created_at": now,
                })),
            )
        }
        Err(e) => {
            warn!(error = %e, "qa_add failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

fn now_iso() -> String {
    let total_secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    let secs_in_day = total_secs % 86400;
    let days = total_secs / 86400;
    let h = secs_in_day / 3600;
    let m = (secs_in_day % 3600) / 60;
    let s = secs_in_day % 60;
    let z = days + 719_468;
    let era = z / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y0 = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if mo <= 2 { y0 + 1 } else { y0 };
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}

async fn load_qa_entries(
    cache: &Arc<crate::cache::NclCache>,
    root: &std::path::Path,
    import_path: Option<&str>,
) -> Vec<serde_json::Value> {
    let qa_path = root.join("reflection").join("qa.ncl");
    if !qa_path.exists() {
        return vec![];
    }
    match cache.export(&qa_path, import_path).await {
        Ok((json, _)) => json
            .get("entries")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default(),
        Err(_) => vec![],
    }
}

// ── Q&A delete / update
// ───────────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct QaDeleteRequest {
    pub id: String,
    pub slug: Option<String>,
}

pub async fn qa_delete(
    State(state): State<AppState>,
    Json(body): Json<QaDeleteRequest>,
) -> impl IntoResponse {
    let (root, cache) = resolve_qa_ctx(&state, body.slug.as_deref());
    let qa_path = root.join("reflection").join("qa.ncl");
    let _guard = state.ncl_write_lock.acquire(&qa_path).await;
    if !qa_path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "qa.ncl not found" })),
        );
    }
    match super::qa_ncl::remove_entry(&qa_path, &body.id) {
        Ok(()) => {
            cache.invalidate_file(&qa_path);
            (
                StatusCode::OK,
                Json(serde_json::json!({ "ok": true, "id": body.id })),
            )
        }
        Err(e) => {
            warn!(error = %e, id = %body.id, "qa_delete failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

#[derive(Deserialize)]
pub struct QaUpdateRequest {
    pub id: String,
    pub question: String,
    pub answer: Option<String>,
    pub slug: Option<String>,
}

pub async fn qa_update(
    State(state): State<AppState>,
    Json(body): Json<QaUpdateRequest>,
) -> impl IntoResponse {
    let (root, cache) = resolve_qa_ctx(&state, body.slug.as_deref());
    let qa_path = root.join("reflection").join("qa.ncl");
    let _guard = state.ncl_write_lock.acquire(&qa_path).await;
    if !qa_path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "qa.ncl not found" })),
        );
    }
    let answer = body.answer.as_deref().unwrap_or("");
    match super::qa_ncl::update_entry(&qa_path, &body.id, &body.question, answer) {
        Ok(()) => {
            cache.invalidate_file(&qa_path);
            (
                StatusCode::OK,
                Json(serde_json::json!({ "ok": true, "id": body.id })),
            )
        }
        Err(e) => {
            warn!(error = %e, id = %body.id, "qa_update failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

fn resolve_qa_ctx(
    state: &crate::api::AppState,
    slug: Option<&str>,
) -> (std::path::PathBuf, Arc<crate::cache::NclCache>) {
    if let Some(s) = slug {
        if let Some(ctx) = state.registry.get(s) {
            return (ctx.root.clone(), ctx.cache.clone());
        }
    }
    (state.project_root.clone(), state.cache.clone())
}

// ── Actions run
// ───────────────────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct ActionRunForm {
    pub action_id: String,
}

pub async fn actions_run(
    State(state): State<AppState>,
    Form(form): Form<ActionRunForm>,
) -> impl IntoResponse {
    let root = state.project_root.clone();
    let cache = state.cache.clone();
    let import_path = state.nickel_import_path.clone();
    run_action_by_id(&root, &cache, import_path.as_deref(), &form.action_id).await;
    axum::response::Redirect::to("/ui/actions").into_response()
}

pub async fn actions_run_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    _auth: AuthUser,
    headers: axum::http::HeaderMap,
    Form(form): Form<ActionRunForm>,
) -> impl IntoResponse {
    if let Some(ctx) = state.registry.get(&slug) {
        run_action_by_id(
            &ctx.root,
            &ctx.cache,
            ctx.import_path.as_deref(),
            &form.action_id,
        )
        .await;
    }
    if headers.contains_key("hx-request") {
        return Html(
            r#"<span class="badge badge-xs badge-success gap-1">▶ launched</span>"#.to_string(),
        )
        .into_response();
    }
    Redirect::to(&format!("/ui/{slug}/actions")).into_response()
}

/// Resolve `action_id` from `quick_actions`, then spawn `./ontoref {mode}`.
async fn run_action_by_id(
    root: &std::path::Path,
    cache: &Arc<crate::cache::NclCache>,
    import_path: Option<&str>,
    action_id: &str,
) {
    let config_path = root.join(".ontoref").join("config.ncl");
    let Ok((json, _)) = cache.export(&config_path, import_path).await else {
        warn!(action_id, "actions_run: config export failed");
        return;
    };

    let mode = json
        .get("quick_actions")
        .and_then(|v| v.as_array())
        .and_then(|arr| {
            arr.iter()
                .find(|a| a.get("id").and_then(|i| i.as_str()) == Some(action_id))
        })
        .and_then(|a| a.get("mode"))
        .and_then(|m| m.as_str())
        .map(str::to_string);

    let Some(mode) = mode else {
        warn!(action_id, "actions_run: action not found in quick_actions");
        return;
    };

    let ontoref_bin = root.join("ontoref");
    if !ontoref_bin.exists() {
        warn!(path = %ontoref_bin.display(), "actions_run: ontoref binary not found");
        return;
    }

    match tokio::process::Command::new(&ontoref_bin)
        .args(["run", &mode])
        .current_dir(root)
        .spawn()
    {
        Ok(_) => tracing::info!(action_id, mode, "action spawned"),
        Err(e) => warn!(action_id, mode, error = %e, "actions_run: spawn failed"),
    }
}

// ── Search bookmarks mutation
// ─────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct BookmarkAddRequest {
    pub node_id: String,
    pub kind: Option<String>,
    pub title: String,
    pub level: Option<String>,
    pub term: Option<String>,
    pub actor: Option<String>,
    pub tags: Option<Vec<String>>,
    pub slug: Option<String>,
}

#[derive(Deserialize)]
pub struct BookmarkDeleteRequest {
    pub id: String,
    pub slug: Option<String>,
}

pub async fn search_bookmark_add(
    State(state): State<AppState>,
    Json(body): Json<BookmarkAddRequest>,
) -> impl IntoResponse {
    let (root, cache) = resolve_bookmark_ctx(&state, body.slug.as_deref());
    let bm_path = root.join("reflection").join("search_bookmarks.ncl");
    let _guard = state.ncl_write_lock.acquire(&bm_path).await;
    if !bm_path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({
                "error": "search_bookmarks.ncl not found — run ontoref setup first"
            })),
        );
    }

    let kind = body.kind.as_deref().unwrap_or("node");
    let level = body.level.as_deref().unwrap_or("");
    let term = body.term.as_deref().unwrap_or("");
    let actor = body.actor.as_deref().unwrap_or("human");
    let tags = body.tags.as_deref().unwrap_or(&[]);
    let now = now_iso();

    match super::search_bookmarks_ncl::add_entry(
        &bm_path,
        super::search_bookmarks_ncl::NewBookmark {
            node_id: &body.node_id,
            kind,
            title: &body.title,
            level,
            term,
            actor,
            created_at: &now,
            tags,
        },
    ) {
        Ok(id) => {
            cache.invalidate_file(&bm_path);
            (
                StatusCode::OK,
                Json(serde_json::json!({
                    "ok":         true,
                    "id":         id,
                    "created_at": now,
                    "node_id":    body.node_id,
                })),
            )
        }
        Err(e) => {
            warn!(error = %e, "search_bookmark_add failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

pub async fn search_bookmark_delete(
    State(state): State<AppState>,
    Json(body): Json<BookmarkDeleteRequest>,
) -> impl IntoResponse {
    let (root, cache) = resolve_bookmark_ctx(&state, body.slug.as_deref());
    let bm_path = root.join("reflection").join("search_bookmarks.ncl");
    let _guard = state.ncl_write_lock.acquire(&bm_path).await;
    if !bm_path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "search_bookmarks.ncl not found" })),
        );
    }
    match super::search_bookmarks_ncl::remove_entry(&bm_path, &body.id) {
        Ok(()) => {
            cache.invalidate_file(&bm_path);
            (StatusCode::OK, Json(serde_json::json!({ "ok": true })))
        }
        Err(e) => {
            warn!(error = %e, "search_bookmark_delete failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

pub(crate) async fn load_bookmark_entries(
    cache: &Arc<crate::cache::NclCache>,
    root: &std::path::Path,
    import_path: Option<&str>,
) -> Vec<serde_json::Value> {
    let bm_path = root.join("reflection").join("search_bookmarks.ncl");
    if !bm_path.exists() {
        return vec![];
    }
    match cache.export(&bm_path, import_path).await {
        Ok((json, _)) => json
            .get("entries")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default(),
        Err(_) => vec![],
    }
}

fn resolve_bookmark_ctx(
    state: &crate::api::AppState,
    slug: Option<&str>,
) -> (std::path::PathBuf, Arc<crate::cache::NclCache>) {
    if let Some(s) = slug {
        if let Some(ctx) = state.registry.get(s) {
            return (ctx.root.clone(), ctx.cache.clone());
        }
    }
    (state.project_root.clone(), state.cache.clone())
}

// ── Config surface page
// ──────────────────────────────────────────────────────

pub async fn config_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let base_url = format!("/ui/{slug}");

    let surface = ctx_ref.config_surface.clone();
    let has_config_surface = surface.is_some();

    let mut ctx = Context::new();
    ctx.insert("slug", &slug);
    ctx.insert("base_url", &base_url);
    ctx.insert("current_role", &auth_role_str(&auth));
    ctx.insert("has_config_surface", &has_config_surface);

    if let Some(ref surface) = surface {
        ctx.insert("config_root", &surface.config_root.display().to_string());
        ctx.insert("entry_point", &surface.entry_point);
        ctx.insert("kind", &format!("{:?}", surface.kind));

        let quickref = crate::config_coherence::build_quickref(
            &slug,
            surface,
            &ctx_ref.root,
            &ctx_ref.cache,
            ctx_ref.import_path.as_deref(),
            None,
        )
        .await;

        let overall_status = quickref
            .get("overall_coherence")
            .and_then(|v| v.as_str())
            .unwrap_or("Unknown");
        ctx.insert("overall_status", overall_status);

        let sections = quickref
            .get("sections")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default();
        ctx.insert("sections", &sections);
    } else {
        ctx.insert("config_root", "");
        ctx.insert("entry_point", "");
        ctx.insert("kind", "");
        ctx.insert("overall_status", "Unknown");
        ctx.insert("sections", &serde_json::Value::Array(vec![]));
    }

    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/config.html", &ctx).await
}

pub async fn adrs_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let base_url = format!("/ui/{slug}");

    let adrs_dir = ctx_ref.root.join("adrs");
    let import_path = ctx_ref.import_path.clone();
    let cache = ctx_ref.cache.clone();

    let mut adrs: Vec<serde_json::Value> = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&adrs_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let stem = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            // Only keep actual ADR files: adr-NNN-* where NNN starts with a digit
            let after_prefix = match stem.strip_prefix("adr-") {
                Some(s) => s,
                None => continue,
            };
            if !after_prefix.starts_with(|c: char| c.is_ascii_digit()) {
                continue;
            }
            match cache.export(&path, import_path.as_deref()).await {
                Ok((v, _)) => {
                    let hard_count = v
                        .get("constraints")
                        .and_then(|c| c.get("hard"))
                        .and_then(|h| h.as_array())
                        .map(|a| a.len())
                        .unwrap_or(0);
                    let soft_count = v
                        .get("constraints")
                        .and_then(|c| c.get("soft"))
                        .and_then(|s| s.as_array())
                        .map(|a| a.len())
                        .unwrap_or(0);
                    adrs.push(serde_json::json!({
                        "id":               v.get("id").and_then(|i| i.as_str()).unwrap_or(&stem),
                        "title":            v.get("title").and_then(|t| t.as_str()).unwrap_or(""),
                        "status":           v.get("status").and_then(|s| s.as_str()).unwrap_or(""),
                        "date":             v.get("date").and_then(|d| d.as_str()).unwrap_or(""),
                        "context":          v.get("context").and_then(|c| c.as_str()).unwrap_or(""),
                        "decision":         v.get("decision").and_then(|d| d.as_str()).unwrap_or(""),
                        "hard_constraints": hard_count,
                        "soft_constraints": soft_count,
                        "file":             stem,
                    }));
                }
                Err(e) => {
                    tracing::warn!(path = %path.display(), error = %e, "adrs_page: export failed");
                    adrs.push(serde_json::json!({
                        "id": stem, "title": "", "status": "Error",
                        "date": "", "context": "", "decision": "",
                        "hard_constraints": 0, "soft_constraints": 0, "file": stem,
                    }));
                }
            }
        }
    }
    adrs.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());

    let adrs_json = serde_json::to_string(&adrs).unwrap_or_else(|_| "[]".to_string());

    let mut ctx = Context::new();
    ctx.insert("slug", &slug);
    ctx.insert("base_url", &base_url);
    ctx.insert("current_role", &auth_role_str(&auth));
    ctx.insert("adrs_json", &adrs_json);
    ctx.insert("adr_count", &adrs.len());
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/adrs.html", &ctx).await
}

// ── Domain extension pages
// ────────────────────────────────────────────────────

/// Personal ontology page — contents pipeline + opportunities.
/// Loads .ontology/personal.ncl and reflection/backlog.ncl (CFP pipeline).
pub async fn personal_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let base_url = format!("/ui/{slug}");
    let import_path = ctx_ref.import_path.clone();
    let cache = ctx_ref.cache.clone();
    let root = ctx_ref.root.clone();

    // personal.ncl → contents + opportunities
    let personal_path = root.join(".ontology").join("personal.ncl");
    let (contents_json, opportunities_json) = if personal_path.exists() {
        match cache.export(&personal_path, import_path.as_deref()).await {
            Ok((v, _)) => {
                let contents = serde_json::to_string(
                    v.get("contents")
                        .unwrap_or(&serde_json::Value::Array(vec![])),
                )
                .unwrap_or_else(|_| "[]".to_string());
                let opps = serde_json::to_string(
                    v.get("opportunities")
                        .unwrap_or(&serde_json::Value::Array(vec![])),
                )
                .unwrap_or_else(|_| "[]".to_string());
                (contents, opps)
            }
            Err(e) => {
                tracing::warn!(error = %e, "personal_page: personal.ncl export failed");
                ("[]".to_string(), "[]".to_string())
            }
        }
    } else {
        ("[]".to_string(), "[]".to_string())
    };

    // reflection/backlog.ncl → cfp pipeline
    let backlog_path = root.join("reflection").join("backlog.ncl");
    let cfp_json = if backlog_path.exists() {
        match cache.export(&backlog_path, import_path.as_deref()).await {
            Ok((v, _)) => {
                serde_json::to_string(v.get("cfp").unwrap_or(&serde_json::Value::Array(vec![])))
                    .unwrap_or_else(|_| "[]".to_string())
            }
            Err(e) => {
                tracing::warn!(error = %e, "personal_page: backlog.ncl export failed");
                "[]".to_string()
            }
        }
    } else {
        "[]".to_string()
    };

    let mut ctx = Context::new();
    ctx.insert("slug", &slug);
    ctx.insert("base_url", &base_url);
    ctx.insert("current_role", &auth_role_str(&auth));
    ctx.insert("contents_json", &contents_json);
    ctx.insert("opportunities_json", &opportunities_json);
    ctx.insert("cfp_json", &cfp_json);
    insert_brand_ctx(
        &mut ctx,
        &root,
        &cache,
        import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/personal.html", &ctx).await
}

/// Career page — skills, work experiences, talks, positioning.
/// Loads .ontology/career.ncl.
pub async fn career_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let base_url = format!("/ui/{slug}");
    let import_path = ctx_ref.import_path.clone();
    let cache = ctx_ref.cache.clone();
    let root = ctx_ref.root.clone();

    let career_path = root.join(".ontology").join("career.ncl");
    let (skills_json, experiences_json, talks_json, positioning_json, publications_json) =
        if career_path.exists() {
            match cache.export(&career_path, import_path.as_deref()).await {
                Ok((v, _)) => {
                    let empty = serde_json::Value::Array(vec![]);
                    let skills = serde_json::to_string(v.get("skills").unwrap_or(&empty))
                        .unwrap_or_else(|_| "[]".to_string());
                    let experiences = serde_json::to_string(v.get("experiences").unwrap_or(&empty))
                        .unwrap_or_else(|_| "[]".to_string());
                    let talks = serde_json::to_string(v.get("talks").unwrap_or(&empty))
                        .unwrap_or_else(|_| "[]".to_string());
                    let positioning = serde_json::to_string(v.get("positioning").unwrap_or(&empty))
                        .unwrap_or_else(|_| "[]".to_string());
                    let publications =
                        serde_json::to_string(v.get("publications").unwrap_or(&empty))
                            .unwrap_or_else(|_| "[]".to_string());
                    (skills, experiences, talks, positioning, publications)
                }
                Err(e) => {
                    tracing::warn!(error = %e, "career_page: career.ncl export failed");
                    (
                        "[]".to_string(),
                        "[]".to_string(),
                        "[]".to_string(),
                        "[]".to_string(),
                        "[]".to_string(),
                    )
                }
            }
        } else {
            (
                "[]".to_string(),
                "[]".to_string(),
                "[]".to_string(),
                "[]".to_string(),
                "[]".to_string(),
            )
        };

    let mut ctx = Context::new();
    ctx.insert("slug", &slug);
    ctx.insert("base_url", &base_url);
    ctx.insert("current_role", &auth_role_str(&auth));
    ctx.insert("skills_json", &skills_json);
    ctx.insert("experiences_json", &experiences_json);
    ctx.insert("talks_json", &talks_json);
    ctx.insert("positioning_json", &positioning_json);
    ctx.insert("publications_json", &publications_json);
    insert_brand_ctx(
        &mut ctx,
        &root,
        &cache,
        import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/career.html", &ctx).await
}

/// Provisioning page — connections and gates.
/// Loads .ontology/connections.ncl and .ontology/gate.ncl.
pub async fn provisioning_page_mp(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    auth: AuthUser,
) -> Result<Html<String>, UiError> {
    let tera = tera_ref(&state)?;
    let ctx_ref = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
    let base_url = format!("/ui/{slug}");
    let import_path = ctx_ref.import_path.clone();
    let cache = ctx_ref.cache.clone();
    let root = ctx_ref.root.clone();

    // connections.ncl → upstream / downstream / peers
    let connections_path = root.join(".ontology").join("connections.ncl");
    let connections_json = if connections_path.exists() {
        match cache
            .export(&connections_path, import_path.as_deref())
            .await
        {
            Ok((v, _)) => serde_json::to_string(&v).unwrap_or_else(|_| "{}".to_string()),
            Err(e) => {
                tracing::warn!(error = %e, "provisioning_page: connections.ncl export failed");
                "{}".to_string()
            }
        }
    } else {
        "{}".to_string()
    };

    // gate.ncl → membranas array
    let gate_path = root.join(".ontology").join("gate.ncl");
    let gates_json = if gate_path.exists() {
        match cache.export(&gate_path, import_path.as_deref()).await {
            Ok((v, _)) => {
                let empty = serde_json::Value::Array(vec![]);
                let membranas = v.get("membranas").unwrap_or(&empty);
                serde_json::to_string(membranas).unwrap_or_else(|_| "[]".to_string())
            }
            Err(e) => {
                tracing::warn!(error = %e, "provisioning_page: gate.ncl export failed");
                "[]".to_string()
            }
        }
    } else {
        "[]".to_string()
    };

    let mut ctx = Context::new();
    ctx.insert("slug", &slug);
    ctx.insert("base_url", &base_url);
    ctx.insert("current_role", &auth_role_str(&auth));
    ctx.insert("connections_json", &connections_json);
    ctx.insert("gates_json", &gates_json);
    insert_brand_ctx(
        &mut ctx,
        &root,
        &cache,
        import_path.as_deref(),
        &base_url,
        &state.registry,
    )
    .await;

    render(tera, "pages/provisioning.html", &ctx).await
}
