use std::sync::Arc;

use axum::extract::{Form, Path, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect};
use axum::Json;
use serde::Deserialize;
use tera::Context;
use tracing::warn;

use super::super::auth::AuthUser;
use super::common::{auth_role_str, insert_brand_ctx, now_iso, render, tera_ref, UiError};
use super::picker::{detect_generated, detect_showcase};
use crate::api::AppState;
use crate::memory_types::ChronicleEntry;

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

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

    let modes_dir =
        ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/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,
        &state.service_flags,
    )
    .await;

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

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 = ontoref_ontology::layout::resolve_section(&ctx_ref.root, "reflection/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,
        &state.service_flags,
    )
    .await;

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

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

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

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()
}

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"),
    }
}

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,
        &state.service_flags,
    )
    .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,
        &state.service_flags,
    )
    .await;
    render(tera, "pages/actions.html", &ctx).await
}

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;
    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 axum::response::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()
}

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

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 = ontoref_ontology::layout::resolve_section(root, "reflection/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![],
    }
}

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())
}

/// Substrate views panel (ADR-046). Read-only — the page fetches /api/views and
/// /api/views/{discipline}/mount client-side, so the handler only renders the
/// shell.
pub async fn views_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);
    insert_brand_ctx(
        &mut ctx,
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        "/ui",
        &state.registry,
        &state.service_flags,
    )
    .await;
    render(tera, "pages/views.html", &ctx).await
}

/// Substrate views panel for a project (multi-project route). The panel fetches
/// the composed recipe client-side from /api/views (the daemon's project root).
pub async fn views_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 mut ctx = Context::new();
    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,
        &state.service_flags,
    )
    .await;
    render(tera, "pages/views.html", &ctx).await
}

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,
        &state.service_flags,
    )
    .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,
        &state.service_flags,
    )
    .await;
    render(tera, "pages/qa.html", &ctx).await
}

#[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 = ontoref_ontology::layout::resolve_section(&root, "reflection/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::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() })),
            )
        }
    }
}

#[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 = ontoref_ontology::layout::resolve_section(&root, "reflection/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::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 = ontoref_ontology::layout::resolve_section(&root, "reflection/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::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() })),
            )
        }
    }
}

// ── Config / ADRs / Domain pages ─────────────────────────────────────────────

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,
        &state.service_flags,
    )
    .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 = ontoref_ontology::layout::resolve_section(&ctx_ref.root, "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();
            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,
        &state.service_flags,
    )
    .await;

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

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

/// Personal ontology page — contents pipeline + opportunities.
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();

    let personal_path = ontoref_ontology::layout::resolve_section(&root, "ontology/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())
    };

    let backlog_path = ontoref_ontology::layout::resolve_section(&root, "reflection/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,
        &state.service_flags,
    )
    .await;

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

/// Career page — skills, work experiences, talks, positioning.
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 = ontoref_ontology::layout::resolve_section(&root, "ontology/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,
        &state.service_flags,
    )
    .await;

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

/// Provisioning page — connections and gates.
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();

    let connections_path =
        ontoref_ontology::layout::resolve_section(&root, "ontology/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()
    };

    let gate_path = ontoref_ontology::layout::resolve_section(&root, "ontology/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,
        &state.service_flags,
    )
    .await;

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

// ── Tier page
// ─────────────────────────────────────────────────────────────────

pub async fn tier_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 ops = ctx_ref.ops();
    let view = ontoref_ops::capabilities::capability_view(ops.tier);
    let projection = ontoref_ops::capabilities::tier_projection(ops.tier);

    // Run precondition check offline (no migration check — just layout).
    let consolidated = ctx_ref.root.join(".ontoref").join("ontology").exists();
    let preconditions = vec![serde_json::json!({
        "name": "layout-consolidated",
        "ok": consolidated,
        "detail": if consolidated { "0023 applied (consolidated)" } else { "0023 not applied — apply with `ore migrate apply 0023`" }
    })];

    let transition_ready = preconditions
        .iter()
        .all(|p| p.get("ok").and_then(|v| v.as_bool()).unwrap_or(false));

    let tier_str = format!("{:?}", ops.tier);
    let phase_str = format!("{:?}", ops.phase);
    let next_tier_str = projection.to.map(|t| format!("{:?}", t));

    let available: Vec<serde_json::Value> = view
        .available
        .iter()
        .map(|c| {
            serde_json::json!({
                "id": c.id,
                "label": c.label,
                "min_tier": format!("{:?}", c.min_tier),
                "description": c.description,
            })
        })
        .collect();

    let locked: Vec<serde_json::Value> = view
        .locked
        .iter()
        .map(|c| {
            serde_json::json!({
                "id": c.id,
                "label": c.label,
                "min_tier": format!("{:?}", c.min_tier),
                "description": c.description,
            })
        })
        .collect();

    let adds: Vec<serde_json::Value> = projection
        .adds
        .iter()
        .map(|c| {
            serde_json::json!({
                "id": c.id,
                "label": c.label,
                "min_tier": format!("{:?}", c.min_tier),
                "description": c.description,
            })
        })
        .collect();

    let mut ctx = Context::new();
    ctx.insert("slug", &slug);
    ctx.insert("base_url", &base_url);
    ctx.insert("tier", &tier_str);
    ctx.insert("phase", &phase_str);
    ctx.insert("next_tier", &next_tier_str);
    ctx.insert("available", &available);
    ctx.insert("locked", &locked);
    ctx.insert("adds", &adds);
    ctx.insert("narrative", &projection.narrative);
    ctx.insert("preconditions", &preconditions);
    ctx.insert("transition_ready", &transition_ready);
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
        &state.service_flags,
    )
    .await;

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

// ── Chronicle timeline
// ────────────────────────────────────────────────────────

/// `GET /{slug}/timeline` — render the chronicle timeline panel.
/// Reads all `entries.jsonl` files under `{root}/.coder/` and serves them as
/// a `ChronicleEntry` array to the vis-timeline template.
pub async fn timeline_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 coder_dir = ctx_ref.root.join(".coder");
    let entries = collect_chronicle_entries(&coder_dir);
    let timeline_json = serde_json::to_string(&entries).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("timeline_json", &timeline_json);
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
        &state.service_flags,
    )
    .await;

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

fn collect_chronicle_entries(coder_dir: &std::path::Path) -> Vec<ChronicleEntry> {
    if !coder_dir.exists() {
        return Vec::new();
    }
    let mut entries: Vec<ChronicleEntry> = Vec::new();
    collect_entries_recursive(coder_dir, &mut entries);
    // Newest-first by date string (lexicographic sort works for ISO dates)
    entries.sort_by(|a, b| b.date.cmp(&a.date));
    entries
}

/// Try the canonical struct first; fall back to lenient Value parsing
/// for entries written by coder.nu which omit 'id' and use 'relates_to'.
fn parse_chronicle_line(line: &str, idx: usize) -> Option<ChronicleEntry> {
    if line.is_empty() {
        return None;
    }
    if let Ok(e) = serde_json::from_str::<ChronicleEntry>(line) {
        return Some(e);
    }
    let v = serde_json::from_str::<serde_json::Value>(line).ok()?;
    let id = v
        .get("id")
        .and_then(|x| x.as_str())
        .map(String::from)
        .unwrap_or_else(|| {
            let date = v.get("date").and_then(|x| x.as_str()).unwrap_or("0000");
            format!("{date}-{idx}")
        });
    let title = v
        .get("title")
        .and_then(|x| x.as_str())
        .unwrap_or("(untitled)")
        .to_string();
    let date = v
        .get("date")
        .and_then(|x| x.as_str())
        .unwrap_or("1970-01-01")
        .to_string();
    let kind = v
        .get("kind")
        .and_then(|x| x.as_str())
        .unwrap_or("info")
        .to_string();
    let refs: Vec<String> = v
        .get("relates_to")
        .or_else(|| v.get("refs"))
        .and_then(|x| x.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|r| r.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();
    let summary = v
        .get("content")
        .or_else(|| v.get("summary"))
        .and_then(|x| x.as_str())
        .map(|s| s.chars().take(200).collect());
    let domain = v.get("domain").and_then(|x| x.as_str()).map(String::from);
    Some(ChronicleEntry {
        id,
        title,
        date,
        kind,
        summary,
        domain,
        refs,
    })
}

fn collect_entries_recursive(dir: &std::path::Path, out: &mut Vec<ChronicleEntry>) {
    let Ok(rd) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in rd.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_entries_recursive(&path, out);
        } else if path.file_name().and_then(|n| n.to_str()) == Some("entries.jsonl") {
            if let Ok(content) = std::fs::read_to_string(&path) {
                out.extend(
                    content
                        .lines()
                        .enumerate()
                        .filter_map(|(idx, l)| parse_chronicle_line(l.trim(), idx)),
                );
            }
        }
    }
}

// ── Positioning roadmap
// ───────────────────────────────────────────────────────

/// `GET /{slug}/roadmap` — render the positioning roadmap panel.
/// Loads positioning data via `Positioning::load` and serves mechanisms +
/// milestones from the `.coder/` milestone store to the vis-timeline template.
pub async fn roadmap_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 pos_dir = ctx_ref.root.join(".ontoref").join("positioning");
    let (mechanisms, milestones, orphan_milestones) = if pos_dir.exists() {
        match ontoref_ontology::ontology::Positioning::load(&pos_dir) {
            Ok(pos) => {
                let mechs: Vec<serde_json::Value> = pos
                        .mechanisms()
                        .iter()
                        .map(|m| serde_json::json!({ "id": m.id, "label": m.name, "status": format!("{:?}", m.status) }))
                        .collect();
                // milestones come from the chronicle entries in .coder/
                let coder_dir = ctx_ref.root.join(".coder");
                let all_entries = collect_chronicle_entries(&coder_dir);
                let ms: Vec<serde_json::Value> = all_entries
                    .into_iter()
                    .filter(|e| e.kind == "milestone")
                    .map(|e| {
                        serde_json::json!({
                            "id": e.id, "title": e.title, "date": e.date,
                            "refs": e.refs, "mechanism": e.refs.first().cloned(),
                        })
                    })
                    .collect();
                let diff_adrs: std::collections::HashSet<String> = pos
                    .differentiators()
                    .iter()
                    .flat_map(|d| d.evidence_adrs.clone())
                    .collect();
                let orphans: Vec<serde_json::Value> = ms
                    .iter()
                    .filter(|m| {
                        let refs = m
                            .get("refs")
                            .and_then(|r| r.as_array())
                            .map(|a| {
                                a.iter()
                                    .filter_map(|v| v.as_str().map(String::from))
                                    .collect::<Vec<_>>()
                            })
                            .unwrap_or_default();
                        !refs.iter().any(|r| diff_adrs.contains(r))
                    })
                    .cloned()
                    .collect();
                (mechs, ms, orphans)
            }
            Err(e) => {
                warn!(slug = %slug, error = %e, "roadmap: positioning load failed");
                (vec![], vec![], vec![])
            }
        }
    } else {
        (vec![], vec![], vec![])
    };

    let roadmap_json = serde_json::to_string(&serde_json::json!({
        "mechanisms": mechanisms,
        "milestones": milestones,
        "orphan_milestones": orphan_milestones,
    }))
    .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("roadmap_json", &roadmap_json);
    ctx.insert("mechanisms", &mechanisms);
    ctx.insert("orphan_count", &orphan_milestones.len());
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
        &state.service_flags,
    )
    .await;

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

// ── Generic panel
// ─────────────────────────────────────────────────────────────

/// `GET /{slug}/panel/{id}` — render a presentation-spec panel by id.
/// Loads the panel NCL from `.ontoref/presentation/panel-{id}.ncl` and
/// delegates data loading to the declared `data_source.op`.
pub async fn panel_page_mp(
    State(state): State<AppState>,
    Path((slug, panel_id)): Path<(String, 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 _ = auth;

    let panel_path = ctx_ref
        .root
        .join(".ontoref")
        .join("presentation")
        .join(format!("panel-{panel_id}.ncl"));

    if !panel_path.exists() {
        return Err(UiError::BadRequest(format!("panel '{panel_id}' not found")));
    }

    let panel = match ctx_ref
        .cache
        .export(&panel_path, ctx_ref.import_path.as_deref())
        .await
    {
        Ok((v, _)) => v,
        Err(e) => {
            warn!(slug = %slug, panel = %panel_id, error = %e, "panel: ncl export failed");
            serde_json::json!({ "id": panel_id, "title": panel_id, "kind": "Table" })
        }
    };

    // panel_data: resolved from the declared op or fallback to empty
    let panel_data_json = serde_json::json!({ "items": [] });
    let panel_data_str =
        serde_json::to_string(&panel_data_json).unwrap_or_else(|_| "{}".to_string());

    let mut ctx = Context::new();
    ctx.insert("slug", &slug);
    ctx.insert("base_url", &base_url);
    ctx.insert("panel", &panel);
    ctx.insert("panel_data_json", &panel_data_str);
    insert_brand_ctx(
        &mut ctx,
        &ctx_ref.root,
        &ctx_ref.cache,
        ctx_ref.import_path.as_deref(),
        &base_url,
        &state.registry,
        &state.service_flags,
    )
    .await;

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