//! Web dashboard for the `mentisdbd` binary.
//!
//! This module exposes a self-contained HTML dashboard at `/dashboard` on a
//! configurable port (default 9475).  All static HTML is embedded via
//! `include_str!` so the binary has no runtime file-system dependency on
//! frontend assets.
//!
//! # Authentication
//!
//! When [`DashboardState::dashboard_pin`] is set, every request under
//! `/dashboard` (except the login page itself) is gated by a PIN check:
//!
//! - `Authorization: Bearer <pin>` HTTP header, **or**
//! - `mentisdb_pin=<pin>` browser cookie (set automatically after a
//!   successful `/dashboard/login` form POST).
//!
//! If neither is present the request is redirected to `/dashboard/login`.

use crate::{
    deregister_chain, load_registered_chains, AgentStatus, MentisDb, PublicKeyAlgorithm,
    SkillFormat, SkillRegistry, StorageAdapterKind, Thought, ThoughtInput, ThoughtQuery,
    ThoughtRole, ThoughtType,
};
use axum::{
    extract::{Path, Query, State},
    http::{header, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Redirect, Response},
    routing::{delete, get, post},
    Form, Json, Router,
};
use dashmap::DashMap;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;

// ── Embedded static HTML ──────────────────────────────────────────────────────

/// Main dashboard page HTML.
const DASHBOARD_HTML: &str = include_str!("dashboard_static/index.html");

/// Login page HTML (used only when a PIN is configured).
const LOGIN_HTML: &str = include_str!("dashboard_static/login.html");

// ── State ─────────────────────────────────────────────────────────────────────

/// Shared state threaded through every dashboard handler.
///
/// All fields wrap their data in `Arc` so cloning the state is cheap; the
/// clone is used by the PIN authentication middleware.
#[derive(Clone)]
pub(crate) struct DashboardState {
    /// Live chain map shared with the REST service.
    pub chains: Arc<DashMap<String, Arc<RwLock<MentisDb>>>>,
    /// Live skill registry shared with the REST service.
    pub skills: Arc<RwLock<SkillRegistry>>,
    /// On-disk directory where chain files are stored.
    pub mentisdb_dir: PathBuf,
    /// Default chain key resolved when none is specified.
    #[allow(dead_code)]
    pub default_chain_key: String,
    /// Optional PIN required to access the dashboard.
    pub dashboard_pin: Option<String>,
    /// Storage adapter kind used when opening chains from disk.
    pub default_storage_adapter: StorageAdapterKind,
    /// Whether newly opened chains should flush immediately on each append.
    #[allow(dead_code)]
    pub auto_flush: bool,
}

// ── Router builder ────────────────────────────────────────────────────────────

/// Build and return the complete dashboard [`Router`].
///
/// Routes under `/dashboard` and `/dashboard/api/**` are protected by the
/// PIN middleware when `state.dashboard_pin` is set.  The login endpoints
/// are always public so the user can authenticate.
pub(crate) fn dashboard_router(state: DashboardState) -> Router {
    // ── API sub-router ────────────────────────────────────────────────────
    let api = Router::new()
        // Chain listing
        .route("/chains", get(api_chains))
        .route("/chains", post(api_bootstrap_chain))
        .route("/chains/{chain_key}", delete(api_delete_chain))
        // Thoughts for a chain
        .route("/chains/{chain_key}/thoughts", get(api_chain_thoughts))
        // Single thought lookup
        .route("/thoughts/{chain_key}/{thought_id}", get(api_get_thought))
        // Thoughts for an agent within a chain
        .route(
            "/chains/{chain_key}/agents/{agent_id}/thoughts",
            get(api_agent_thoughts),
        )
        // Agent listing — all chains
        .route("/agents", get(api_agents_all))
        // Agent listing — single chain
        .route("/agents/{chain_key}", get(api_agents_by_chain))
        // Single-agent read + patch
        .route(
            "/agents/{chain_key}/{agent_id}",
            get(api_get_agent).patch(api_patch_agent),
        )
        // Agent lifecycle mutations
        .route(
            "/agents/{chain_key}/{agent_id}/revoke",
            post(api_revoke_agent),
        )
        .route(
            "/agents/{chain_key}/{agent_id}/activate",
            post(api_activate_agent),
        )
        // Agent key management
        .route(
            "/agents/{chain_key}/{agent_id}/keys",
            post(api_add_agent_key),
        )
        .route(
            "/agents/{chain_key}/{agent_id}/keys/{key_id}",
            delete(api_delete_agent_key),
        )
        // Agent memory export
        .route(
            "/agents/{chain_key}/{agent_id}/memory-markdown",
            get(api_agent_memory_markdown),
        )
        // Bulk import from MEMORY.md format
        .route(
            "/chains/{chain_key}/import-markdown",
            post(api_import_markdown),
        )
        // Copy agent memories to another chain
        .route(
            "/agents/{chain_key}/{agent_id}/copy-to/{target_chain_key}",
            post(api_copy_agent_to_chain),
        )
        // Skill listing and reading
        .route("/skills", get(api_skills))
        .route("/skills/{skill_id}", get(api_get_skill))
        .route("/skills/{skill_id}/versions", get(api_skill_versions))
        .route("/skills/{skill_id}/diff", get(api_skill_diff))
        .route("/skills/{skill_id}/revoke", post(api_revoke_skill))
        .route("/skills/{skill_id}/deprecate", post(api_deprecate_skill))
        // Version
        .route("/version", get(api_version));

    // ── Protected surface (PIN-gated when pin is set) ─────────────────────
    let protected = Router::new()
        .route("/dashboard", get(serve_dashboard))
        .route("/dashboard/", get(serve_dashboard))
        .nest("/dashboard/api", api)
        .layer(middleware::from_fn_with_state(
            state.clone(),
            pin_auth_middleware,
        ));

    // ── Full dashboard router ─────────────────────────────────────────────
    Router::new()
        .merge(protected)
        .route("/dashboard/login", get(serve_login))
        .route("/dashboard/login", post(handle_login))
        .with_state(state)
}

// ── PIN authentication middleware ─────────────────────────────────────────────

/// Axum middleware that enforces the dashboard PIN.
///
/// Passes the request through unchanged when no PIN is configured.
/// When a PIN is set it accepts:
///
/// - `Authorization: Bearer <pin>` header
/// - `mentisdb_pin=<pin>` cookie
///
/// Any other request is redirected to `/dashboard/login`.
async fn pin_auth_middleware(
    State(state): State<DashboardState>,
    request: axum::extract::Request,
    next: Next,
) -> Response {
    let Some(required_pin) = &state.dashboard_pin else {
        // No PIN configured — open access.
        return next.run(request).await;
    };

    let headers = request.headers();

    // ── Check Authorization: Bearer <pin> header ──────────────────────────
    if let Some(auth_val) = headers.get(header::AUTHORIZATION) {
        if let Ok(auth_str) = auth_val.to_str() {
            if let Some(provided) = auth_str.strip_prefix("Bearer ") {
                if provided == required_pin.as_str() {
                    return next.run(request).await;
                }
            }
        }
    }

    // ── Check mentisdb_pin cookie ─────────────────────────────────────────
    if let Some(cookie_val) = headers.get(header::COOKIE) {
        if let Ok(cookie_str) = cookie_val.to_str() {
            for part in cookie_str.split(';') {
                if let Some(pin) = part.trim().strip_prefix("mentisdb_pin=") {
                    if pin == required_pin.as_str() {
                        return next.run(request).await;
                    }
                }
            }
        }
    }

    // ── Neither matched — redirect to login ───────────────────────────────
    Redirect::to("/dashboard/login").into_response()
}

// ── Static HTML handlers ──────────────────────────────────────────────────────

/// Serve the main dashboard HTML.
async fn serve_dashboard() -> impl IntoResponse {
    (
        StatusCode::OK,
        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
        DASHBOARD_HTML,
    )
}

/// Serve the login page HTML.
async fn serve_login() -> impl IntoResponse {
    (
        StatusCode::OK,
        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
        LOGIN_HTML,
    )
}

// ── Login POST handler ────────────────────────────────────────────────────────

/// Form body for the `/dashboard/login` POST.
#[derive(Deserialize)]
struct LoginForm {
    pin: String,
}

/// Handle a login form submission.
///
/// On success sets the `mentisdb_pin` cookie and redirects to `/dashboard`.
/// On failure redirects back to `/dashboard/login?error=1`.
async fn handle_login(
    State(state): State<DashboardState>,
    Form(form): Form<LoginForm>,
) -> Response {
    let pin_matches = state
        .dashboard_pin
        .as_deref()
        .map(|required| form.pin == required)
        .unwrap_or(true); // No PIN configured → any submission succeeds.

    if pin_matches {
        (
            StatusCode::SEE_OTHER,
            [
                (
                    header::SET_COOKIE,
                    format!(
                        "mentisdb_pin={}; Path=/; HttpOnly; SameSite=Strict",
                        form.pin
                    ),
                ),
                (header::LOCATION, "/dashboard".to_string()),
            ],
            "",
        )
            .into_response()
    } else {
        Redirect::to("/dashboard/login?error=1").into_response()
    }
}

// ── Shared helpers ────────────────────────────────────────────────────────────

/// Build a `500 Internal Server Error` JSON response.
fn internal_error(err: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
    (
        StatusCode::INTERNAL_SERVER_ERROR,
        Json(json!({ "error": err.to_string() })),
    )
}

/// Build a `404 Not Found` JSON response.
fn not_found(msg: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
    (
        StatusCode::NOT_FOUND,
        Json(json!({ "error": msg.to_string() })),
    )
}

/// Look up a chain in the live cache; fall back to opening it from disk.
///
/// The opened chain is inserted into `state.chains` so subsequent requests
/// can reuse it without touching the file system.
async fn get_or_open_chain(
    state: &DashboardState,
    chain_key: &str,
) -> Result<Arc<RwLock<MentisDb>>, (StatusCode, Json<Value>)> {
    // Try the live cache first (clone the Arc to avoid holding the DashMap shard lock across an await).
    if let Some(arc) = state.chains.get(chain_key).map(|r| r.value().clone()) {
        return Ok(arc);
    }

    // Open from disk.
    let chain = MentisDb::open_with_key_and_storage_kind(
        &state.mentisdb_dir,
        chain_key,
        state.default_storage_adapter,
    )
    .map_err(|e| not_found(format!("chain '{chain_key}': {e}")))?;

    let arc = Arc::new(RwLock::new(chain));
    state.chains.insert(chain_key.to_string(), arc.clone());
    Ok(arc)
}

/// Map a string token to a [`ThoughtType`] variant.
///
/// Returns `None` for any unrecognised name.
fn parse_thought_type(s: &str) -> Option<ThoughtType> {
    match s.trim() {
        "PreferenceUpdate" => Some(ThoughtType::PreferenceUpdate),
        "UserTrait" => Some(ThoughtType::UserTrait),
        "RelationshipUpdate" => Some(ThoughtType::RelationshipUpdate),
        "Finding" => Some(ThoughtType::Finding),
        "Insight" => Some(ThoughtType::Insight),
        "FactLearned" => Some(ThoughtType::FactLearned),
        "PatternDetected" => Some(ThoughtType::PatternDetected),
        "Hypothesis" => Some(ThoughtType::Hypothesis),
        "Mistake" => Some(ThoughtType::Mistake),
        "Correction" => Some(ThoughtType::Correction),
        "LessonLearned" => Some(ThoughtType::LessonLearned),
        "AssumptionInvalidated" => Some(ThoughtType::AssumptionInvalidated),
        "Constraint" => Some(ThoughtType::Constraint),
        "Plan" => Some(ThoughtType::Plan),
        "Subgoal" => Some(ThoughtType::Subgoal),
        "Decision" => Some(ThoughtType::Decision),
        "StrategyShift" => Some(ThoughtType::StrategyShift),
        "Wonder" => Some(ThoughtType::Wonder),
        "Question" => Some(ThoughtType::Question),
        "Idea" => Some(ThoughtType::Idea),
        "Experiment" => Some(ThoughtType::Experiment),
        "ActionTaken" => Some(ThoughtType::ActionTaken),
        "TaskComplete" => Some(ThoughtType::TaskComplete),
        "Checkpoint" => Some(ThoughtType::Checkpoint),
        "StateSnapshot" => Some(ThoughtType::StateSnapshot),
        "Handoff" => Some(ThoughtType::Handoff),
        "Summary" => Some(ThoughtType::Summary),
        "Reframe" => Some(ThoughtType::Reframe),
        "Surprise" => Some(ThoughtType::Surprise),
        _ => None,
    }
}

// ── API response shape helpers ────────────────────────────────────────────────

/// Serialise a page of thoughts alongside pagination metadata.
///
/// When `reverse` is `true` the slice is returned newest-first (descending by
/// append index). Pagination is applied in streaming order so the full filtered
/// result set does not need to be reversed or materialized up front.
fn paginated_thoughts<F>(
    thoughts: &[Thought],
    page: usize,
    per_page: usize,
    reverse: bool,
    mut predicate: F,
) -> Value
where
    F: FnMut(&Thought) -> bool,
{
    let page = page.max(1);
    let per_page = per_page.max(1);
    let start = (page.saturating_sub(1)).saturating_mul(per_page);
    let mut total = 0usize;
    let mut slice = Vec::with_capacity(per_page);

    if reverse {
        for thought in thoughts.iter().rev() {
            if !predicate(thought) {
                continue;
            }
            if total >= start && slice.len() < per_page {
                slice.push(thought);
            }
            total += 1;
        }
    } else {
        for thought in thoughts {
            if !predicate(thought) {
                continue;
            }
            if total >= start && slice.len() < per_page {
                slice.push(thought);
            }
            total += 1;
        }
    }

    let pages = total.div_ceil(per_page);

    json!({
        "thoughts": slice,
        "total": total,
        "page": page,
        "per_page": per_page,
        "pages": pages,
    })
}

fn thought_counts_by_agent(thoughts: &[Thought]) -> HashMap<&str, u64> {
    let mut counts = HashMap::new();
    for thought in thoughts {
        *counts.entry(thought.agent_id.as_str()).or_insert(0) += 1;
    }
    counts
}

// ── Query parameter structs ───────────────────────────────────────────────────

/// Query parameters for thought-listing endpoints.
#[derive(Deserialize, Default)]
struct ThoughtsQuery {
    /// 1-based page number (defaults to 1).
    page: Option<usize>,
    /// Items per page (defaults to 50).
    per_page: Option<usize>,
    /// Comma-separated list of [`ThoughtType`] names to filter by.
    types: Option<String>,
    /// Sort order: `"asc"` (oldest first) or `"desc"` (newest first, default).
    order: Option<String>,
}

/// Query parameters for the skill-diff endpoint.
#[derive(Deserialize)]
struct DiffQuery {
    /// Version UUID to use as the "before" side of the diff.
    from: Option<String>,
    /// Version UUID to use as the "after" side of the diff.
    to: Option<String>,
}

// ── API: chain listing ────────────────────────────────────────────────────────

/// `GET /dashboard/api/chains`
///
/// Returns a JSON array of chain summaries with live thought and agent counts.
/// Includes both chains registered on disk and any chains currently live in the
/// in-memory DashMap cache (e.g. created mid-session via MCP/REST).
async fn api_chains(
    State(state): State<DashboardState>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    // Collect all known chain keys: disk registry ∪ live DashMap.
    let mut chain_keys: std::collections::BTreeSet<String> = {
        let registry = load_registered_chains(&state.mentisdb_dir).map_err(internal_error)?;
        registry.chains.into_keys().collect()
    };
    for entry in state.chains.iter() {
        chain_keys.insert(entry.key().clone());
    }

    let mut chains = Vec::with_capacity(chain_keys.len());

    for chain_key in &chain_keys {
        // Open (or retrieve from cache) to guarantee live counts.
        let arc = get_or_open_chain(&state, chain_key).await?;
        let chain = arc.read().await;
        chains.push(json!({
            "chain_key": chain_key,
            "thought_count": chain.thoughts().len(),
            "agent_count":   chain.agent_registry().agents.len(),
            "head_hash":     chain.head_hash().map(ToString::to_string),
        }));
    }

    Ok(Json(json!(chains)))
}

// ── API: bootstrap chain ──────────────────────────────────────────────────────

/// JSON body for `POST /dashboard/api/chains`.
#[derive(Deserialize)]
struct BootstrapChainBody {
    chain_key: String,
    content: String,
    agent_id: Option<String>,
    tags: Option<Vec<String>>,
    concepts: Option<Vec<String>>,
    importance: Option<f32>,
}

/// `POST /dashboard/api/chains`
///
/// Bootstraps a new chain (creates it and appends a bootstrap thought if it
/// is empty). Returns `{"bootstrapped": true/false, "chain_key": "..."}`.
async fn api_bootstrap_chain(
    State(state): State<DashboardState>,
    Json(body): Json<BootstrapChainBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let chain_key = body.chain_key.trim().to_string();
    if chain_key.is_empty() {
        return Err((
            StatusCode::BAD_REQUEST,
            Json(json!({"error": "chain_key must not be empty"})),
        ));
    }
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let mut chain = arc.write().await;
    let bootstrapped = if chain.thoughts().is_empty() {
        let agent_id = body.agent_id.as_deref().unwrap_or("system");
        let input = ThoughtInput::new(ThoughtType::Summary, body.content.clone())
            .with_role(ThoughtRole::Checkpoint)
            .with_importance(body.importance.unwrap_or(1.0))
            .with_tags(body.tags.clone().unwrap_or_default())
            .with_concepts(body.concepts.clone().unwrap_or_default());
        chain
            .append_thought(agent_id, input)
            .map_err(internal_error)?;
        true
    } else {
        false
    };
    Ok(Json(
        json!({ "bootstrapped": bootstrapped, "chain_key": chain_key }),
    ))
}

// ── API: delete chain ─────────────────────────────────────────────────────────

/// `DELETE /dashboard/api/chains/:chain_key`
///
/// Permanently deletes a chain: removes its storage file, deregisters it from
/// the registry, and evicts it from the in-memory cache.
async fn api_delete_chain(
    State(state): State<DashboardState>,
    Path(chain_key): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    // Evict from in-memory cache first so no new writes can sneak in.
    state.chains.remove(&chain_key);
    // Deregister + delete storage file.
    deregister_chain(&state.mentisdb_dir, &chain_key).map_err(internal_error)?;
    Ok(Json(json!({ "deleted": true, "chain_key": chain_key })))
}

// ── API: thoughts ─────────────────────────────────────────────────────────────

/// `GET /dashboard/api/chains/:chain_key/thoughts?page=1&per_page=50&types=Decision,Insight`
///
/// Returns a paginated list of thoughts from the requested chain, optionally
/// filtered by a comma-separated list of [`ThoughtType`] names.
async fn api_chain_thoughts(
    State(state): State<DashboardState>,
    Path(chain_key): Path<String>,
    Query(params): Query<ThoughtsQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let chain = arc.read().await;

    let type_filter: Option<Vec<ThoughtType>> = params
        .types
        .as_deref()
        .map(|raw| raw.split(',').filter_map(parse_thought_type).collect());

    let page = params.page.unwrap_or(1).max(1);
    let per_page = params.per_page.unwrap_or(50).max(1);
    let reverse = params.order.as_deref().unwrap_or("desc") != "asc";

    Ok(Json(paginated_thoughts(
        chain.thoughts(),
        page,
        per_page,
        reverse,
        |t| {
            type_filter
                .as_ref()
                .map(|types| types.contains(&t.thought_type))
                .unwrap_or(true)
        },
    )))
}

/// `GET /dashboard/api/thoughts/:chain_key/:thought_id`
///
/// Returns a single thought identified by its UUID.
async fn api_get_thought(
    State(state): State<DashboardState>,
    Path((chain_key, thought_id_str)): Path<(String, String)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let thought_id = thought_id_str.parse::<Uuid>().map_err(|e| {
        (
            StatusCode::BAD_REQUEST,
            Json(json!({ "error": e.to_string() })),
        )
    })?;

    let arc = get_or_open_chain(&state, &chain_key).await?;
    let chain = arc.read().await;

    let thought = chain.get_thought_by_id(thought_id).ok_or_else(|| {
        not_found(format!(
            "thought '{thought_id}' not found in chain '{chain_key}'"
        ))
    })?;

    Ok(Json(serde_json::to_value(thought).map_err(internal_error)?))
}

/// `GET /dashboard/api/chains/:chain_key/agents/:agent_id/thoughts?page=1&per_page=50&types=...`
///
/// Returns a paginated list of thoughts authored by the given agent.
async fn api_agent_thoughts(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id)): Path<(String, String)>,
    Query(params): Query<ThoughtsQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let chain = arc.read().await;

    let type_filter: Option<Vec<ThoughtType>> = params
        .types
        .as_deref()
        .map(|raw| raw.split(',').filter_map(parse_thought_type).collect());

    let page = params.page.unwrap_or(1).max(1);
    let per_page = params.per_page.unwrap_or(50).max(1);
    let reverse = params.order.as_deref().unwrap_or("desc") != "asc";

    Ok(Json(paginated_thoughts(
        chain.thoughts(),
        page,
        per_page,
        reverse,
        |t| {
            t.agent_id == agent_id
                && type_filter
                    .as_ref()
                    .map(|types| types.contains(&t.thought_type))
                    .unwrap_or(true)
        },
    )))
}

// ── API: agents ───────────────────────────────────────────────────────────────

/// `GET /dashboard/api/agents`
///
/// Returns all registered agents across all known chains, keyed by chain key.
async fn api_agents_all(
    State(state): State<DashboardState>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let registry = load_registered_chains(&state.mentisdb_dir).map_err(internal_error)?;

    let mut result: BTreeMap<String, Vec<Value>> = BTreeMap::new();

    for chain_key in registry.chains.keys() {
        match get_or_open_chain(&state, chain_key).await {
            Ok(arc) => {
                let chain = arc.read().await;
                let thoughts = chain.thoughts();
                let thought_counts = thought_counts_by_agent(thoughts);
                let agents: Vec<Value> = chain
                    .agent_registry()
                    .agents
                    .values()
                    .map(|a| {
                        let live_count = thought_counts
                            .get(a.agent_id.as_str())
                            .copied()
                            .unwrap_or(0);
                        let mut v = serde_json::to_value(a).unwrap_or(Value::Null);
                        if let Value::Object(ref mut m) = v {
                            m.insert("thought_count".to_string(), live_count.into());
                        }
                        v
                    })
                    .collect();
                result.insert(chain_key.clone(), agents);
            }
            Err(_) => {
                result.insert(chain_key.clone(), Vec::new());
            }
        }
    }

    Ok(Json(serde_json::to_value(result).map_err(internal_error)?))
}

/// `GET /dashboard/api/agents/:chain_key`
///
/// Returns all registered agents for the given chain.
async fn api_agents_by_chain(
    State(state): State<DashboardState>,
    Path(chain_key): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let chain = arc.read().await;
    let thoughts = chain.thoughts();
    let thought_counts = thought_counts_by_agent(thoughts);
    let agents: Vec<Value> = chain
        .agent_registry()
        .agents
        .values()
        .map(|a| {
            let live_count = thought_counts
                .get(a.agent_id.as_str())
                .copied()
                .unwrap_or(0);
            let mut v = serde_json::to_value(a).unwrap_or(Value::Null);
            if let Value::Object(ref mut m) = v {
                m.insert("thought_count".to_string(), live_count.into());
            }
            v
        })
        .collect();
    Ok(Json(serde_json::to_value(agents).map_err(internal_error)?))
}

/// `GET /dashboard/api/agents/:chain_key/:agent_id`
///
/// Returns a single agent record from the given chain.
async fn api_get_agent(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id)): Path<(String, String)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let chain = arc.read().await;
    let agent = chain
        .agent_registry()
        .agents
        .get(&agent_id)
        .ok_or_else(|| {
            not_found(format!(
                "agent '{agent_id}' not found in chain '{chain_key}'"
            ))
        })?;
    let thought_counts = thought_counts_by_agent(chain.thoughts());
    let live_count = thought_counts.get(agent_id.as_str()).copied().unwrap_or(0);
    let mut v = serde_json::to_value(agent).map_err(internal_error)?;
    if let Value::Object(ref mut m) = v {
        m.insert("thought_count".to_string(), live_count.into());
    }
    Ok(Json(v))
}

// ── Agent mutation helpers ────────────────────────────────────────────────────

/// JSON body for `PATCH /dashboard/api/agents/:chain_key/:agent_id`.
#[derive(Deserialize)]
struct AgentPatchBody {
    display_name: Option<String>,
    description: Option<String>,
    agent_owner: Option<String>,
}

/// `PATCH /dashboard/api/agents/:chain_key/:agent_id`
///
/// Updates one or more mutable fields on an agent record and persists the
/// registry to disk.
async fn api_patch_agent(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id)): Path<(String, String)>,
    Json(body): Json<AgentPatchBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let mut chain = arc.write().await;

    let agent = chain
        .upsert_agent(
            &agent_id,
            body.display_name.as_deref(),
            body.agent_owner.as_deref(),
            body.description.as_deref(),
            None, // status not changed via PATCH
        )
        .map_err(internal_error)?;

    Ok(Json(serde_json::to_value(agent).map_err(internal_error)?))
}

/// `POST /dashboard/api/agents/:chain_key/:agent_id/revoke`
///
/// Marks the agent as [`AgentStatus::Revoked`] and persists the registry.
async fn api_revoke_agent(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id)): Path<(String, String)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let mut chain = arc.write().await;

    let agent = chain
        .upsert_agent(&agent_id, None, None, None, Some(AgentStatus::Revoked))
        .map_err(internal_error)?;

    Ok(Json(serde_json::to_value(agent).map_err(internal_error)?))
}

/// `POST /dashboard/api/agents/:chain_key/:agent_id/activate`
///
/// Marks the agent as [`AgentStatus::Active`] and persists the registry.
async fn api_activate_agent(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id)): Path<(String, String)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let mut chain = arc.write().await;

    let agent = chain
        .upsert_agent(&agent_id, None, None, None, Some(AgentStatus::Active))
        .map_err(internal_error)?;

    Ok(Json(serde_json::to_value(agent).map_err(internal_error)?))
}

/// JSON body for `POST /dashboard/api/agents/:chain_key/:agent_id/keys`.
#[derive(Deserialize)]
struct AddKeyBody {
    key_id: String,
    algorithm: String,
    public_key_bytes: Vec<u8>,
}

/// `POST /dashboard/api/agents/:chain_key/:agent_id/keys`
///
/// Registers a new public verification key on the agent record.
async fn api_add_agent_key(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id)): Path<(String, String)>,
    Json(body): Json<AddKeyBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let algorithm = body
        .algorithm
        .parse::<PublicKeyAlgorithm>()
        .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))))?;

    let arc = get_or_open_chain(&state, &chain_key).await?;
    let mut chain = arc.write().await;

    let agent = chain
        .add_agent_key(&agent_id, &body.key_id, algorithm, body.public_key_bytes)
        .map_err(internal_error)?;

    Ok(Json(serde_json::to_value(agent).map_err(internal_error)?))
}

/// `DELETE /dashboard/api/agents/:chain_key/:agent_id/keys/:key_id`
///
/// Revokes the specified public key on the agent record.
async fn api_delete_agent_key(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id, key_id)): Path<(String, String, String)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let mut chain = arc.write().await;

    let agent = chain
        .revoke_agent_key(&agent_id, &key_id)
        .map_err(internal_error)?;

    Ok(Json(serde_json::to_value(agent).map_err(internal_error)?))
}

// ── API: skills ───────────────────────────────────────────────────────────────

/// `GET /dashboard/api/skills`
///
/// Returns a summary list of all registered skills.
async fn api_skills(
    State(state): State<DashboardState>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let skills = state.skills.read().await;
    let list = skills.list_skills();
    Ok(Json(serde_json::to_value(list).map_err(internal_error)?))
}

/// `GET /dashboard/api/skills/:skill_id`
///
/// Returns the summary and latest Markdown content for a skill.
async fn api_get_skill(
    State(state): State<DashboardState>,
    Path(skill_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let skills = state.skills.read().await;

    let summary = skills
        .list_skills()
        .into_iter()
        .find(|s| s.skill_id == skill_id)
        .ok_or_else(|| not_found(format!("skill '{skill_id}' not found")))?;

    let markdown = skills
        .read_skill(&skill_id, None, SkillFormat::Markdown)
        .map_err(internal_error)?;

    Ok(Json(json!({ "summary": summary, "markdown": markdown })))
}

/// `GET /dashboard/api/skills/:skill_id/versions`
///
/// Returns the full version history for a skill.
async fn api_skill_versions(
    State(state): State<DashboardState>,
    Path(skill_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let skills = state.skills.read().await;
    let versions = skills.skill_versions(&skill_id).map_err(internal_error)?;
    Ok(Json(
        serde_json::to_value(versions).map_err(internal_error)?,
    ))
}

/// `GET /dashboard/api/skills/:skill_id/diff?from=<version_id>&to=<version_id>`
///
/// Produces a unified diff between two versions of a skill.
/// When `from` or `to` are omitted the latest version is used for the
/// respective side.
async fn api_skill_diff(
    State(state): State<DashboardState>,
    Path(skill_id): Path<String>,
    Query(params): Query<DiffQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let skills = state.skills.read().await;

    let parse_version_id = |raw: Option<&str>| -> Result<Option<Uuid>, (StatusCode, Json<Value>)> {
        match raw {
            Some(s) => s.parse::<Uuid>().map(Some).map_err(|e| {
                (
                    StatusCode::BAD_REQUEST,
                    Json(json!({ "error": e.to_string() })),
                )
            }),
            None => Ok(None),
        }
    };

    let from_id = parse_version_id(params.from.as_deref())?;
    let to_id = parse_version_id(params.to.as_deref())?;

    let old_content = skills
        .read_skill(&skill_id, from_id, SkillFormat::Markdown)
        .map_err(internal_error)?;

    let new_content = skills
        .read_skill(&skill_id, to_id, SkillFormat::Markdown)
        .map_err(internal_error)?;

    let patch = diffy::create_patch(&old_content, &new_content);
    Ok(Json(json!({ "diff": patch.to_string() })))
}

#[derive(Deserialize)]
struct SkillStatusBody {
    reason: Option<String>,
}

/// `POST /dashboard/api/skills/:skill_id/revoke`
///
/// Marks the skill as revoked. The skill's content and version history are
/// preserved for auditability.
async fn api_revoke_skill(
    State(state): State<DashboardState>,
    Path(skill_id): Path<String>,
    Json(body): Json<SkillStatusBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let mut skills = state.skills.write().await;
    let summary = skills
        .revoke_skill(&skill_id, body.reason.as_deref())
        .map_err(internal_error)?;
    Ok(Json(serde_json::to_value(summary).map_err(internal_error)?))
}

/// `POST /dashboard/api/skills/:skill_id/deprecate`
///
/// Marks the skill as deprecated.
async fn api_deprecate_skill(
    State(state): State<DashboardState>,
    Path(skill_id): Path<String>,
    Json(body): Json<SkillStatusBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let mut skills = state.skills.write().await;
    let summary = skills
        .deprecate_skill(&skill_id, body.reason.as_deref())
        .map_err(internal_error)?;
    Ok(Json(serde_json::to_value(summary).map_err(internal_error)?))
}

/// `GET /dashboard/api/version`
///
/// Returns the crate version baked in at compile time.
async fn api_version() -> Json<Value> {
    Json(json!({ "version": env!("CARGO_PKG_VERSION") }))
}

/// `GET /dashboard/api/agents/{chain_key}/{agent_id}/memory-markdown`
///
/// Exports all thoughts attributed to `agent_id` on `chain_key` as a
/// `MEMORY.md`-style Markdown document. The response includes the rendered
/// markdown and a suggested filename for "Save As" download.
async fn api_agent_memory_markdown(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id)): Path<(String, String)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let chain = arc.read().await;

    // Filter all thoughts to only this agent's contributions.
    let query = ThoughtQuery::new().with_agent_ids([agent_id.as_str()]);
    let markdown = chain.to_memory_markdown(Some(&query));

    // Build a filesystem-safe suggested filename:
    //   <agent_id>_<chain_key>_AGENT.md  (spaces → underscores, lowercased)
    let safe = |s: &str| {
        s.chars()
            .map(|c| {
                if c.is_alphanumeric() || c == '-' {
                    c.to_ascii_lowercase()
                } else {
                    '_'
                }
            })
            .collect::<String>()
    };
    let filename = format!("{}_{}_AGENT.md", safe(&agent_id), safe(&chain_key));

    Ok(Json(json!({ "markdown": markdown, "filename": filename })))
}

/// Request body for `POST /dashboard/api/chains/{chain_key}/import-markdown`.
#[derive(Debug, Deserialize)]
struct ImportMarkdownBody {
    /// MEMORY.md formatted markdown content to import.
    markdown: String,
    /// Agent ID to use when a parsed line contains no `agent` token.
    /// Defaults to `"default"` when absent.
    default_agent_id: Option<String>,
}

/// `POST /dashboard/api/chains/{chain_key}/import-markdown`
///
/// Import a MEMORY.md-formatted markdown string into the specified chain,
/// appending each successfully-parsed thought.  Lines that do not match the
/// expected bullet format are silently skipped.
///
/// # Request body
///
/// ```json
/// { "markdown": "...", "default_agent_id": "agent-123" }
/// ```
///
/// # Response
///
/// ```json
/// { "imported": [0, 1, 2], "count": 3 }
/// ```
async fn api_import_markdown(
    State(state): State<DashboardState>,
    Path(chain_key): Path<String>,
    Json(body): Json<ImportMarkdownBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let arc = get_or_open_chain(&state, &chain_key).await?;
    let mut chain = arc.write().await;
    let default_agent_id = body.default_agent_id.as_deref().unwrap_or("default");
    let imported = chain
        .import_from_memory_markdown(&body.markdown, default_agent_id)
        .map_err(internal_error)?;
    let count = imported.len();
    Ok(Json(json!({ "imported": imported, "count": count })))
}

/// `POST /dashboard/api/agents/{chain_key}/{agent_id}/copy-to/{target_chain_key}`
///
/// Copies every thought attributed to `agent_id` on the source chain
/// (`chain_key`) to `target_chain_key` as new append-only entries, preserving
/// all semantic fields (type, role, content, tags, concepts, confidence,
/// importance).
///
/// # Constraints
///
/// - If `agent_id` already has at least one thought on the target chain the
///   request is rejected with `409 Conflict`. This avoids the complexity of
///   syncing diverged histories whose hashes will never match.
/// - Cross-chain positional `refs` and typed `relations` are intentionally
///   dropped: they reference thought indices / UUIDs that belong to the
///   source chain and are meaningless on the target chain.
/// - The agent's display name and owner are propagated via the first appended
///   thought so the agent registry on the target chain is populated correctly.
///
/// # Response
///
/// ```json
/// { "copied": 42 }
/// ```
async fn api_copy_agent_to_chain(
    State(state): State<DashboardState>,
    Path((chain_key, agent_id, target_chain_key)): Path<(String, String, String)>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    if chain_key == target_chain_key {
        return Err((
            StatusCode::BAD_REQUEST,
            Json(json!({ "error": "source and target chain must differ" })),
        ));
    }

    // Open source chain (read-only snapshot).
    let src_arc = get_or_open_chain(&state, &chain_key).await?;
    let src_chain = src_arc.read().await;

    // Collect thoughts belonging to this agent (oldest first).
    let agent_thoughts: Vec<ThoughtInput> = {
        // Retrieve agent metadata for name/owner propagation.
        let (agent_name, agent_owner): (String, Option<String>) = src_chain
            .get_agent(&agent_id)
            .map(|a| (a.display_name.clone(), a.owner.clone()))
            .unwrap_or_else(|| (String::new(), None));

        src_chain
            .thoughts()
            .iter()
            .filter(|t| t.agent_id == agent_id)
            .enumerate()
            .map(|(i, t)| {
                let mut input = ThoughtInput::new(t.thought_type, t.content.clone());
                input.role = t.role;
                input.importance = t.importance;
                input.confidence = t.confidence;
                input.tags = t.tags.clone();
                input.concepts = t.concepts.clone();
                // Propagate agent metadata on the first thought so the target
                // chain's agent registry entry is populated with the correct
                // display name and owner.
                if i == 0 {
                    if !agent_name.is_empty() {
                        input.agent_name = Some(agent_name.clone());
                    }
                    if let Some(ref owner) = agent_owner {
                        if !owner.is_empty() {
                            input.agent_owner = Some(owner.clone());
                        }
                    }
                }
                // refs and relations are positional/UUID references into the
                // source chain; they cannot be meaningfully carried over.
                input
            })
            .collect()
    };
    drop(src_chain);

    if agent_thoughts.is_empty() {
        return Ok(Json(json!({ "copied": 0 })));
    }

    // Open (or create) the target chain.
    let dst_arc = {
        // create_new=false: open if it exists, create if it doesn't
        let chain = MentisDb::open_with_key_and_storage_kind(
            &state.mentisdb_dir,
            &target_chain_key,
            state.default_storage_adapter,
        )
        .map_err(|e| internal_error(format!("open target chain '{target_chain_key}': {e}")))?;
        let arc = Arc::new(RwLock::new(chain));
        state.chains.insert(target_chain_key.clone(), arc.clone());
        arc
    };

    let mut dst_chain = dst_arc.write().await;

    // Guard: reject if the agent already has thoughts on the target chain.
    let already_exists = dst_chain.thoughts().iter().any(|t| t.agent_id == agent_id);
    if already_exists {
        return Err((
            StatusCode::CONFLICT,
            Json(json!({
                "error": format!(
                    "agent '{agent_id}' already has thoughts on chain '{target_chain_key}'; \
                     copying would create a diverged history"
                )
            })),
        ));
    }

    let mut copied = 0usize;
    for input in agent_thoughts {
        dst_chain
            .append_thought(&agent_id, input)
            .map_err(|e| internal_error(format!("append thought: {e}")))?;
        copied += 1;
    }

    Ok(Json(json!({ "copied": copied })))
}
