use std::net::IpAddr;
use std::path::PathBuf;
#[cfg(any(feature = "mcp", feature = "graphql"))]
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{delete, get, post, put};
use axum::Json;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tracing::{error, warn};

use crate::actors::{ActorRegistry, ActorSessionView, RegisterRequest};
use crate::cache::NclCache;
use crate::notifications::{AckRequest, EmitRequest, NotificationStore, NotificationView};
use crate::watcher::FileWatcher;

/// Fixed-window per-IP rate limiter for failed Bearer authentication attempts.
///
/// Disabled automatically when the daemon binds to a loopback address — rate
/// limiting is only meaningful when external clients can reach the endpoint.
/// When `enabled = false`, every method is a no-op.
pub struct AuthRateLimiter {
    enabled: bool,
    window_secs: u64,
    max_failures: u32,
    counts: DashMap<IpAddr, (u32, Instant)>,
}

impl AuthRateLimiter {
    pub fn new(enabled: bool) -> Self {
        Self {
            enabled,
            window_secs: 60,
            max_failures: 10,
            counts: DashMap::new(),
        }
    }

    /// Returns `true` if this IP has exceeded the failure threshold within the
    /// window.
    pub fn is_limited(&self, ip: IpAddr) -> bool {
        if !self.enabled {
            return false;
        }
        self.counts
            .get(&ip)
            .is_some_and(|e| e.1.elapsed().as_secs() < self.window_secs && e.0 >= self.max_failures)
    }

    /// Record a failed attempt. Returns `true` if the IP is now rate-limited.
    pub fn record_failure(&self, ip: IpAddr) -> bool {
        if !self.enabled {
            return false;
        }
        let now = Instant::now();
        let mut entry = self.counts.entry(ip).or_insert((0, now));
        if entry.1.elapsed().as_secs() >= self.window_secs {
            entry.0 = 0;
            entry.1 = now;
        }
        entry.0 += 1;
        entry.0 >= self.max_failures
    }

    /// Clear failure count after a successful authentication.
    pub fn record_success(&self, ip: IpAddr) {
        if self.enabled {
            self.counts.remove(&ip);
        }
    }
}

/// Returns true if `s` has the format of a UUID v4 (36 chars, hyphens at
/// positions 8/13/18/23). Used to distinguish session tokens from raw passwords
/// in `check_primary_auth` without needing to attempt argon2 on token strings.
#[cfg(feature = "ui")]
fn is_uuid_v4(s: &str) -> bool {
    if s.len() != 36 {
        return false;
    }
    let b = s.as_bytes();
    b[8] == b'-'
        && b[13] == b'-'
        && b[18] == b'-'
        && b[23] == b'-'
        && b.iter()
            .enumerate()
            .all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
}

/// Runtime on/off flags for optional services compiled into the binary.
///
/// Toggles are `AtomicBool` so middleware reads are a single load instruction
/// with no lock contention on the hot path.
pub struct ServiceFlags {
    #[cfg(feature = "mcp")]
    pub mcp: AtomicBool,
    #[cfg(feature = "graphql")]
    pub graphql: AtomicBool,
}

impl ServiceFlags {
    pub fn new() -> Arc<Self> {
        Arc::new(Self {
            #[cfg(feature = "mcp")]
            mcp: AtomicBool::new(true),
            #[cfg(feature = "graphql")]
            graphql: AtomicBool::new(true),
        })
    }

    /// Returns `true` if the named service is enabled (or not compiled in).
    pub fn is_enabled(&self, service: &str) -> bool {
        match service {
            #[cfg(feature = "mcp")]
            "mcp" => self.mcp.load(Ordering::Relaxed),
            #[cfg(feature = "graphql")]
            "graphql" => self.graphql.load(Ordering::Relaxed),
            _ => false,
        }
    }

    /// Toggle a named service. Returns the new state, or `None` if the service
    /// is unknown or not compiled in.
    #[cfg_attr(
        not(any(feature = "mcp", feature = "graphql")),
        allow(unused_variables)
    )]
    pub fn set(&self, service: &str, enabled: bool) -> Option<bool> {
        match service {
            #[cfg(feature = "mcp")]
            "mcp" => {
                self.mcp.store(enabled, Ordering::Relaxed);
                Some(enabled)
            }
            #[cfg(feature = "graphql")]
            "graphql" => {
                self.graphql.store(enabled, Ordering::Relaxed);
                Some(enabled)
            }
            _ => None,
        }
    }
}

// Cannot be derived: AtomicBool::default() is false, but services start
// enabled.
#[allow(clippy::derivable_impls)]
impl Default for ServiceFlags {
    fn default() -> Self {
        Self {
            #[cfg(feature = "mcp")]
            mcp: AtomicBool::new(true),
            #[cfg(feature = "graphql")]
            graphql: AtomicBool::new(true),
        }
    }
}

/// Shared application state injected into handlers.
#[derive(Clone)]
pub struct AppState {
    pub cache: Arc<NclCache>,
    pub project_root: PathBuf,
    pub ontoref_root: Option<PathBuf>,
    pub started_at: Instant,
    pub last_activity: Arc<AtomicU64>,
    pub actors: Arc<ActorRegistry>,
    pub notifications: Arc<NotificationStore>,
    /// Resolved NICKEL_IMPORT_PATH for UI-initiated NCL exports.
    pub nickel_import_path: Option<String>,
    #[cfg(feature = "db")]
    pub db: Option<Arc<stratum_db::StratumDb>>,
    #[cfg(feature = "nats")]
    pub nats: Option<Arc<crate::nats::NatsPublisher>>,
    #[cfg(feature = "ui")]
    pub tera: Option<Arc<tokio::sync::RwLock<ui::TeraEnv>>>,
    #[cfg(feature = "ui")]
    pub public_dir: Option<PathBuf>,
    /// Project registry. Always present — primary project is always registered
    /// under `registry.primary_slug()`. Registry and primary project auth share
    /// the same `ProjectContext` code path; `primary_keys` is gone.
    pub registry: Arc<crate::registry::ProjectRegistry>,
    /// Channel to start a file watcher for a project added at runtime.
    pub new_project_tx:
        Option<Arc<tokio::sync::mpsc::UnboundedSender<Arc<crate::registry::ProjectContext>>>>,
    /// Path to `~/.config/ontoref/` — used to persist key overrides.
    pub config_dir: Option<std::path::PathBuf>,
    #[cfg(feature = "ui")]
    pub sessions: Arc<crate::session::SessionStore>,
    /// Current project set by `set_project` MCP tool — shared across all
    /// connections.
    #[cfg(feature = "mcp")]
    pub mcp_current_project: Arc<std::sync::RwLock<Option<String>>>,
    /// Live watchers for runtime-added registry projects.
    /// Keyed by slug; removing an entry drops the `FileWatcher` and stops
    /// watching.
    pub watcher_map: Arc<tokio::sync::Mutex<std::collections::HashMap<String, FileWatcher>>>,
    /// Per-IP failed auth rate limiter. Disabled on loopback binds.
    pub auth_rate_limiter: Arc<AuthRateLimiter>,
    /// Per-path mutex registry for NCL file mutations (backlog, QA).
    /// Serializes concurrent read-mutate-write sequences on the same file.
    #[cfg(feature = "ui")]
    pub ncl_write_lock: Arc<crate::ui::ncl_write::NclWriteLock>,
    /// Argon2id hash of the daemon-level admin password.
    /// Set via ONTOREF_ADMIN_TOKEN (inline hash) or ONTOREF_ADMIN_TOKEN_FILE
    /// (path to a file containing the hash). When present, /ui/manage/login
    /// accepts this password and creates a "_daemon" session with Role::Admin.
    #[cfg(feature = "ui")]
    pub daemon_admin_hash: Option<String>,
    /// Argon2id PHC hash of the GraphQL read token from
    /// `config.graphql.read_token_hash`. `None` means GraphQL endpoints
    /// require no dedicated token.
    #[cfg(feature = "graphql")]
    pub graphql_read_token_hash: Option<String>,
    /// Runtime enable/disable flags for optional services (MCP, GraphQL).
    /// Shared across all clones of AppState — toggling affects all connections.
    pub service_flags: Arc<ServiceFlags>,
    /// Ed25519 token issuer for SSO-compatible JWTs.
    ///
    /// Exposes its JWKS at `GET /.well-known/ontoref-keys.json` and is the
    /// signing key for tokens accepted by SSO-mode panels (federation contract
    /// with sibling crates that consume `ontoref-auth`'s
    /// `SignedTokenProvider`).
    pub token_issuer: Arc<auth::TokenIssuer>,
    /// Verifies daemon-minted JWTs against the issuer's JWKS. Refreshed in
    /// lockstep with `token_issuer` on key rotation.
    pub identity_provider: Arc<auth::SignedTokenProvider>,
}

impl AppState {
    /// Verify a raw token against `graphql_read_token_hash`.
    ///
    /// Returns `true` when:
    /// - no hash is configured (`None`) — open access, or
    /// - the token matches the configured argon2id hash.
    #[cfg(feature = "graphql")]
    pub fn verify_graphql_token(&self, token: &str) -> bool {
        let Some(ref hash) = self.graphql_read_token_hash else {
            return true;
        };
        use crate::registry::{verify_keys_list, KeyEntry, Role};
        let synthetic = vec![KeyEntry {
            role: Role::Viewer,
            hash: hash.clone(),
            label: "graphql".into(),
        }];
        verify_keys_list(&synthetic, token).is_some()
    }

    fn touch_activity(&self) {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        self.last_activity.store(now, Ordering::Relaxed);
    }

    /// Check `Authorization: Bearer` against the primary project's keys.
    ///
    /// Accepts two credential forms (tried in order):
    /// 1. Session token (UUID v4) — looked up in SessionStore; O(1), cheap.
    /// 2. Raw password — verified via argon2id against project keys; O(~100ms).
    ///
    /// Returns `None` when the request should proceed, `Some(response)` for
    /// 429 (rate-limited) or 401 (bad/missing credentials).
    pub(crate) fn check_primary_auth(
        &self,
        headers: &axum::http::HeaderMap,
        client_ip: IpAddr,
    ) -> Option<axum::response::Response> {
        if self.auth_rate_limiter.is_limited(client_ip) {
            return Some(
                (
                    StatusCode::TOO_MANY_REQUESTS,
                    Json(serde_json::json!({
                        "error": "too many failed auth attempts",
                        "retry_after_secs": 60
                    })),
                )
                    .into_response(),
            );
        }
        let primary = self.registry.primary();
        if !primary.auth_enabled() {
            return None;
        }
        let Some(bearer) = extract_bearer(headers) else {
            return Some(
                (
                    StatusCode::UNAUTHORIZED,
                    Json(serde_json::json!({
                        "error": "Authorization: Bearer <session-token|password> required"
                    })),
                )
                    .into_response(),
            );
        };

        // Try session token first (UUID v4 format).
        #[cfg(feature = "ui")]
        if is_uuid_v4(bearer) {
            if let Some(entry) = self.sessions.get(bearer) {
                if entry.slug == primary.slug || entry.slug == "_daemon" {
                    self.auth_rate_limiter.record_success(client_ip);
                    return None;
                }
            }
            // UUID format but not a valid session — fall through to password
            // check.
        }

        // Password verification (backward-compatible).
        if primary.verify_key(bearer).is_some() {
            self.auth_rate_limiter.record_success(client_ip);
            None
        } else {
            self.auth_rate_limiter.record_failure(client_ip);
            Some(
                (
                    StatusCode::UNAUTHORIZED,
                    Json(serde_json::json!({"error": "invalid credentials"})),
                )
                    .into_response(),
            )
        }
    }

    /// The slug of the primary (local) project.
    pub fn default_project_name(&self) -> String {
        self.registry.primary_slug().to_string()
    }
}

/// Validate a Bearer session token and return the `SessionEntry`.
///
/// Only accepts UUID v4 tokens (not raw passwords). Returns a 401 response on
/// any failure so callers can propagate directly with `?`.
#[cfg(feature = "ui")]
fn require_session(
    state: &AppState,
    headers: &axum::http::HeaderMap,
) -> Result<crate::session::SessionEntry, Box<axum::response::Response>> {
    let Some(bearer) = extract_bearer(headers) else {
        return Err(Box::new(
            (
                StatusCode::UNAUTHORIZED,
                Json(
                    serde_json::json!({"error": "Authorization: Bearer <session-token> required"}),
                ),
            )
                .into_response(),
        ));
    };
    if !is_uuid_v4(bearer) {
        return Err(Box::new(
            (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({"error": "session token required; use POST /sessions to obtain one"})),
            )
                .into_response(),
        ));
    }
    state.sessions.get(bearer).ok_or_else(|| {
        Box::new(
            (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({"error": "invalid or expired session"})),
            )
                .into_response(),
        )
    })
}

/// Extract the raw password from `Authorization: Bearer <password>`.
pub(crate) fn extract_bearer(headers: &axum::http::HeaderMap) -> Option<&str> {
    headers
        .get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .map(str::trim)
        .filter(|s| !s.is_empty())
}

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

pub fn router(state: AppState) -> axum::Router {
    let app = axum::Router::new()
        // Existing endpoints
        .route("/api/catalog", get(api_catalog_handler))
        .route("/health", get(health))
        .route("/nickel/export", post(nickel_export))
        .route("/cache/stats", get(cache_stats))
        .route("/cache/invalidate", post(cache_invalidate))
        // Operations surface (ADR-024 / O5) — converges on
        // ontoref-ops::dispatch_op via crate::ops_surface.
        .route("/ops/{op_id}", post(ops_dispatch_handler))
        .route("/ops", get(ops_list_handler))
        // Actor endpoints
        .route("/actors/register", post(actor_register))
        .route("/actors/{token}", delete(actor_deregister))
        .route("/actors/{token}/touch", post(actor_touch))
        .route("/actors/{token}/profile", post(actor_update_profile))
        .route("/actors", get(actors_list))
        // Notification endpoints
        .route("/notifications/pending", get(notifications_pending))
        .route("/notifications/ack", post(notifications_ack))
        .route("/notifications/emit", post(notifications_emit))
        // SSE push stream — actor subscribes once instead of polling.
        .route("/notifications/stream", get(notifications_stream))
        // Git hook endpoint — actor signs a file-change it caused.
        .route("/ontology/changed", post(ontology_changed))
        // Search endpoint
        .route("/search", get(search))
        // Describe endpoints
        .route("/describe/project", get(describe_project))
        .route("/describe/capabilities", get(describe_capabilities))
        .route("/describe/connections", get(describe_connections))
        .route("/describe/actor-init", get(describe_actor_init))
        .route("/describe/guides", get(describe_guides))
        .route("/describe/config", get(describe_config))
        // Tier introspection endpoints (ADR-029 / ADR-033)
        .route("/tier/status", get(tier_status))
        .route("/tier/next", get(tier_next))
        // Domain graph navigation
        .route("/domain/list", get(domain_list))
        .route("/domain/consumers", get(domain_consumers))
        .route("/domain/show", get(domain_show))
        // Positioning endpoints (ADR-035) — marketing as queryable surface
        .route("/api/positioning", get(crate::positioning::positioning_get))
        .route(
            "/api/positioning/audience/{id}",
            get(crate::positioning::positioning_audience),
        )
        .route(
            "/api/positioning/value-prop/{id}",
            get(crate::positioning::positioning_value_prop),
        )
        .route(
            "/api/positioning/audit",
            post(crate::positioning::positioning_audit),
        )
        // Memory surface (component J, ADR-051/053) — chronicle / roadmap / criteria / panels
        .route("/api/chronicle", get(chronicle_handler))
        .route("/api/roadmap", get(roadmap_handler))
        .route("/api/criteria", get(criteria_handler))
        .route("/api/panels", get(panels_handler))
        // Substrate view endpoints (ADR-046) — composed recipe over HTTP/MCP
        .route("/api/views", get(crate::views::views_list))
        .route(
            "/api/views/{discipline}/mount",
            get(crate::views::views_mount),
        )
        // ADR read + validation endpoints
        .route("/validate/adrs", get(validate_adrs))
        .route("/adr/{id}", get(get_adr))
        // File content (for graph UI artifact links)
        .route("/file", get(get_file_content))
        // Ontology extension endpoints
        .route("/ontology", get(list_ontology_extensions))
        .route("/ontology/{file}", get(get_ontology_extension))
        // Graph endpoints (impact analysis + federation)
        .route("/graph/impact", get(graph_impact))
        .route("/graph/node/{id}", get(graph_node))
        // Backlog endpoints
        .route("/backlog-json", get(backlog_json))
        .route("/backlog/propose-status", post(backlog_propose_status))
        // Q&A read endpoint
        .route("/qa-json", get(qa_json))
        // Push-based sync endpoint: projects export their NCL and POST here.
        // Body limited to 4 MiB — ontology payloads are small; larger bodies are rejected.
        // Auth: Bearer token verified against project keys (see sync.rs).
        .route(
            "/sync",
            post(crate::sync::sync_push)
                .layer(axum::extract::DefaultBodyLimit::max(4 * 1024 * 1024)),
        )
        // Runtime key rotation for registered projects.
        // Requires Bearer token with admin role (or no auth if project has no keys yet).
        .route("/projects/{slug}/keys", put(project_update_keys))
        // Per-file ontology version counters — incremented on every cache invalidation.
        .route(
            "/projects/{slug}/ontology/versions",
            get(project_file_versions),
        )
        // Project registry management.
        .route("/projects", get(projects_list).post(project_add))
        .route("/projects/{slug}", delete(project_delete))
        // Config surface — read
        .route("/projects/{slug}/config", get(project_config))
        .route("/projects/{slug}/config/schema", get(project_config_schema))
        .route(
            "/projects/{slug}/config/coherence",
            get(project_config_coherence),
        )
        .route(
            "/projects/{slug}/config/quickref",
            get(project_config_quickref),
        )
        .route(
            "/projects/{slug}/config/{section}",
            get(project_config_section),
        )
        // Config surface — cross-project comparison (no slug)
        .route("/config/cross-project", get(config_cross_project))
        // Config surface — mutation via override layer (admin only)
        .route(
            "/projects/{slug}/config/{section}",
            put(project_config_update),
        );

    // Service toggle endpoints — daemon admin protected.
    let app = app
        .route("/services", get(services_status))
        .route("/services/{service}", put(service_toggle));

    // SSO surface: JWKS publication + token introspection (RFC 7662-shaped).
    // No auth on either endpoint:
    // - JWKS is public by definition (anyone verifying a token needs it).
    // - Introspection takes the token in the request body and reveals only what was
    //   already cryptographically vouched by the issuer.
    let app = app
        .route("/.well-known/ontoref-keys.json", get(jwks_handler))
        .route("/.session/introspect", post(introspect_handler));

    // Session endpoints — gated on ui feature (requires SessionStore).
    #[cfg(feature = "ui")]
    let app = app
        .route("/sessions", post(session_create).get(sessions_list))
        .route("/sessions/{id}", delete(session_revoke));

    // Gate the mutation endpoint behind the ui feature (requires crate::ui).
    #[cfg(feature = "ui")]
    let app = app
        .route("/qa/add", post(crate::ui::handlers::qa_add))
        .route("/qa/delete", post(crate::ui::handlers::qa_delete))
        .route("/qa/update", post(crate::ui::handlers::qa_update))
        .route(
            "/search/bookmark/add",
            post(crate::ui::handlers::search_bookmark_add),
        )
        .route(
            "/search/bookmark/delete",
            post(crate::ui::handlers::search_bookmark_delete),
        );

    let app = app.with_state(state.clone());

    #[cfg(feature = "ui")]
    let app = {
        use axum::response::Redirect;
        use tower_http::services::ServeDir;

        let app = app
            .route("/ui", get(|| async { Redirect::permanent("/ui/") }))
            .nest("/ui/", crate::ui::router(state.clone()));

        if let Some(ref public_dir) = state.public_dir {
            app.nest_service("/public", ServeDir::new(public_dir))
        } else {
            app
        }
    };

    // MCP streamable-HTTP endpoint — stateless per-request factory.
    // Toggle middleware checks service_flags.mcp before forwarding.
    #[cfg(feature = "mcp")]
    let app = {
        use rmcp::transport::streamable_http_server::{
            session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
        };
        let mcp_flags = Arc::clone(&state.service_flags);
        let mcp_state = state.clone();
        let mcp_svc = StreamableHttpService::new(
            move || Ok(crate::mcp::OntoreServer::new(mcp_state.clone())),
            std::sync::Arc::new(LocalSessionManager::default()),
            StreamableHttpServerConfig::default(),
        );
        let mcp_router =
            axum::Router::new()
                .fallback_service(mcp_svc)
                .layer(axum::middleware::from_fn(
                    move |req: axum::extract::Request, next: axum::middleware::Next| {
                        let flags = Arc::clone(&mcp_flags);
                        async move {
                            if !flags.mcp.load(Ordering::Relaxed) {
                                return StatusCode::SERVICE_UNAVAILABLE.into_response();
                            }
                            next.run(req).await
                        }
                    },
                ));
        app.nest("/mcp", mcp_router)
    };

    // GraphQL endpoint — schema built once with AppState; GraphiQL at GET /graphql.
    // Toggle middleware checks service_flags.graphql before forwarding.
    #[cfg(feature = "graphql")]
    let app = {
        let gql_flags = Arc::clone(&state.service_flags);
        let gql_router =
            crate::graphql::graphql_router(&state).route_layer(axum::middleware::from_fn(
                move |req: axum::extract::Request, next: axum::middleware::Next| {
                    let flags = Arc::clone(&gql_flags);
                    async move {
                        if !flags.graphql.load(Ordering::Relaxed) {
                            return StatusCode::SERVICE_UNAVAILABLE.into_response();
                        }
                        next.run(req).await
                    }
                },
            ));
        app.nest("/graphql", gql_router)
    };

    app
}

// ── Health ──────────────────────────────────────────────────────────────

#[derive(Serialize)]
struct HealthResponse {
    status: &'static str,
    uptime_secs: u64,
    cache_entries: usize,
    cache_hits: u64,
    cache_misses: u64,
    project_root: String,
    active_actors: usize,
    ontology_version: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    db_enabled: Option<bool>,
}

/// Full catalog of daemon HTTP endpoints with metadata: auth, actors, params,
/// tags
///
/// Returns all entries sorted by path then method.
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/api/catalog",
    auth = "none",
    actors = "agent, developer, ci, admin",
    tags = "meta, catalog"
)]
async fn api_catalog_handler() -> impl IntoResponse {
    let routes = crate::api_catalog::catalog();
    Json(serde_json::json!({ "count": routes.len(), "routes": routes }))
}

/// Dispatch a domain operation by id (ADR-024 / O5). All three op
/// surfaces — MCP `tools/call`, HTTP `POST /ops/{id}`, CLI
/// `ontoref-daemon --invoke-op` — converge on
/// [`crate::ops_surface::dispatch_op_request`], which calls
/// [`ontoref_ops::dispatch_op`] and returns a signed witness.
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/ops/{op_id}",
    auth = "bearer",
    actors = "agent, developer, ci, admin",
    description = "Dispatch a domain operation by catalog id; returns the signed pipeline witness",
    params = "op_id:string:required:Catalog id of the operation",
    tags = "ops, dispatch"
)]
async fn ops_dispatch_handler(
    axum::extract::Path(op_id): axum::extract::Path<String>,
    Json(request): Json<crate::ops_surface::DispatchRequest>,
) -> Result<
    Json<crate::ops_surface::DispatchResponse>,
    (axum::http::StatusCode, Json<serde_json::Value>),
> {
    match crate::ops_surface::dispatch_op_request(&op_id, request) {
        Ok(response) => Ok(Json(response)),
        Err(e) => Err((
            axum::http::StatusCode::BAD_REQUEST,
            Json(serde_json::json!({ "error": e.to_string(), "op_id": op_id })),
        )),
    }
}

/// List every registered operation. Surfaces the same projection that
/// MCP `tools/list` consumes — name, description, validators,
/// render paths, witness shape.
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/ops",
    auth = "none",
    actors = "agent, developer, ci, admin",
    description = "List every registered domain operation with its declarative shape (MCP-aligned \
                   projection)",
    tags = "ops, catalog"
)]
async fn ops_list_handler() -> Json<serde_json::Value> {
    let tools = crate::ops_surface::ops_as_mcp_tools();
    Json(serde_json::json!({ "count": tools.len(), "ops": tools }))
}

// ── Memory surface (component J) ────────────────────────────────────────────

#[derive(Deserialize)]
struct ChronicleQuery {
    since: Option<String>,
    kind: Option<String>,
    domain: Option<String>,
    slug: Option<String>,
}

/// Chronicle timeline entries woven from Accepted ADRs and protocol migrations
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/api/chronicle",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "since:string:optional:ISO date lower bound (inclusive); \
              kind:string:optional:adr|migration; domain:string:optional:Domain filter; \
              slug:string:optional:Project slug",
    tags = "memory, chronicle"
)]
async fn chronicle_handler(
    State(state): State<AppState>,
    Query(q): Query<ChronicleQuery>,
) -> Json<serde_json::Value> {
    state.touch_activity();
    let (root, cache, ip) = project_ctx_tuple(&state, q.slug.as_deref());
    let entries = crate::memory::chronicle_entries(
        &root,
        &cache,
        ip.as_deref(),
        q.since.as_deref(),
        q.kind.as_deref(),
        q.domain.as_deref(),
    )
    .await;
    Json(serde_json::json!({ "count": entries.len(), "entries": entries }))
}

#[derive(Deserialize)]
struct RoadmapQuery {
    status: Option<String>,
    horizon: Option<String>,
    slug: Option<String>,
}

/// Roadmap items derived from the project backlog
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/api/roadmap",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "status:string:optional:Open|InProgress|Done|Cancelled; \
              horizon:string:optional:now|next|later; slug:string:optional:Project slug",
    tags = "memory, roadmap"
)]
async fn roadmap_handler(
    State(state): State<AppState>,
    Query(q): Query<RoadmapQuery>,
) -> Json<serde_json::Value> {
    state.touch_activity();
    let (root, cache, ip) = project_ctx_tuple(&state, q.slug.as_deref());
    let items = crate::memory::roadmap_items(
        &root,
        &cache,
        ip.as_deref(),
        q.status.as_deref(),
        q.horizon.as_deref(),
    )
    .await;
    Json(serde_json::json!({ "count": items.len(), "items": items }))
}

#[derive(Deserialize)]
struct CriteriaQuery {
    kind: Option<String>,
    slug: Option<String>,
}

/// Criteria promoted from the QA trace to catalog validators (ADR-049/050)
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/api/criteria",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "kind:string:optional:normative (all current criteria are normative); \
              slug:string:optional:Project slug",
    tags = "memory, criteria"
)]
async fn criteria_handler(
    State(state): State<AppState>,
    Query(q): Query<CriteriaQuery>,
) -> Json<serde_json::Value> {
    state.touch_activity();
    let (root, cache, ip) = project_ctx_tuple(&state, q.slug.as_deref());
    let criteria =
        crate::memory::criteria_list(&root, &cache, ip.as_deref(), q.kind.as_deref()).await;
    Json(serde_json::json!({ "count": criteria.len(), "criteria": criteria }))
}

#[derive(Deserialize)]
struct PanelsQuery {
    kind: Option<String>,
    slug: Option<String>,
}

/// Resolved panel specs from the presentation directory (ADR-053)
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/api/panels",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "kind:string:optional:Timeline|Gantt|Graph|Table|Board; slug:string:optional:Project \
              slug",
    tags = "memory, panels"
)]
async fn panels_handler(
    State(state): State<AppState>,
    Query(q): Query<PanelsQuery>,
) -> Json<serde_json::Value> {
    state.touch_activity();
    let (root, cache, ip) = project_ctx_tuple(&state, q.slug.as_deref());
    let panels = crate::memory::panels_list(&root, &cache, ip.as_deref(), q.kind.as_deref()).await;
    Json(serde_json::json!({ "count": panels.len(), "panels": panels }))
}

/// Daemon health check: uptime, version, feature flags, active projects
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/health",
    auth = "none",
    actors = "agent, developer, ci, admin",
    tags = "meta"
)]
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
    state.touch_activity();
    let db_enabled = {
        #[cfg(feature = "db")]
        {
            Some(state.db.is_some())
        }
        #[cfg(not(feature = "db"))]
        {
            None
        }
    };
    Json(HealthResponse {
        status: "ok",
        uptime_secs: state.started_at.elapsed().as_secs(),
        cache_entries: state.cache.len(),
        cache_hits: state.cache.hit_count(),
        cache_misses: state.cache.miss_count(),
        project_root: state.project_root.display().to_string(),
        active_actors: state.actors.count(),
        ontology_version: state
            .registry
            .primary()
            .ontology_version
            .load(Ordering::Acquire),
        db_enabled,
    })
}

// ── Nickel Export ───────────────────────────────────────────────────────

#[derive(Deserialize)]
struct ExportRequest {
    path: String,
    import_path: Option<String>,
}

#[derive(Serialize)]
struct ExportResponse {
    data: serde_json::Value,
    cached: bool,
    elapsed_ms: u64,
}

/// Export a Nickel file to JSON, using the cache when the file is unchanged
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/nickel/export",
    auth = "viewer",
    actors = "developer, agent",
    params = "file:string:required:Absolute path to the .ncl file to export; \
              import_path:string:optional:NICKEL_IMPORT_PATH override",
    tags = "nickel, cache"
)]
async fn nickel_export(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
    Json(req): Json<ExportRequest>,
) -> axum::response::Response {
    state.touch_activity();

    if let Some(resp) = state.check_primary_auth(&headers, addr.ip()) {
        return resp;
    }

    let start = Instant::now();

    // Accept absolute paths — daemon is loopback-only; OS permissions are the
    // boundary. Relative paths are still resolved against project_root for
    // backward compatibility.
    let file_path = match resolve_any_path(&state.project_root, &req.path) {
        Ok(p) => p,
        Err((status, msg)) => {
            return (status, Json(serde_json::json!({"error": msg}))).into_response()
        }
    };

    let inherited_ip = std::env::var("NICKEL_IMPORT_PATH").unwrap_or_default();
    let merged_ip: Option<String> = match req.import_path.as_deref() {
        Some(caller_ip) => {
            if inherited_ip.is_empty() {
                Some(caller_ip.to_string())
            } else {
                Some(format!("{caller_ip}:{inherited_ip}"))
            }
        }
        None => {
            if inherited_ip.is_empty() {
                None
            } else {
                Some(inherited_ip)
            }
        }
    };

    let (data, was_hit) = match state.cache.export(&file_path, merged_ip.as_deref()).await {
        Ok(r) => r,
        Err(e) => {
            error!(path = %file_path.display(), error = %e, "nickel export failed");
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({"error": e.to_string()})),
            )
                .into_response();
        }
    };

    Json(ExportResponse {
        data,
        cached: was_hit,
        elapsed_ms: start.elapsed().as_millis() as u64,
    })
    .into_response()
}

// ── Cache Management ────────────────────────────────────────────────────

#[derive(Serialize)]
struct CacheStatsResponse {
    entries: usize,
    hits: u64,
    misses: u64,
    hit_rate: f64,
}

/// NCL export cache statistics: entry count, hit/miss counters
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/cache/stats",
    auth = "viewer",
    actors = "developer, admin",
    tags = "cache, meta"
)]
async fn cache_stats(State(state): State<AppState>) -> Json<CacheStatsResponse> {
    state.touch_activity();
    let hits = state.cache.hit_count();
    let misses = state.cache.miss_count();
    let total = hits + misses;
    Json(CacheStatsResponse {
        entries: state.cache.len(),
        hits,
        misses,
        hit_rate: if total > 0 {
            hits as f64 / total as f64
        } else {
            0.0
        },
    })
}

#[derive(Deserialize)]
struct InvalidateRequest {
    /// Optional project slug. When present, invalidates the registry project's
    /// cache instead of the primary project cache.
    slug: Option<String>,
    prefix: Option<String>,
    file: Option<String>,
    all: Option<bool>,
}

#[derive(Serialize)]
struct InvalidateResponse {
    invalidated: bool,
    entries_remaining: usize,
}

/// Invalidate one or all NCL cache entries, forcing re-export on next request
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/cache/invalidate",
    auth = "admin",
    actors = "developer, admin",
    params = "file:string:optional:Specific file path to invalidate (omit to invalidate all)",
    tags = "cache"
)]
async fn cache_invalidate(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
    Json(req): Json<InvalidateRequest>,
) -> std::result::Result<Json<InvalidateResponse>, (StatusCode, String)> {
    state.touch_activity();

    if let Some(resp) = state.check_primary_auth(&headers, addr.ip()) {
        let status = resp.status();
        return Err((status, "authentication required".to_string()));
    }

    // Resolve which cache to operate on.
    let cache = match req.slug.as_deref() {
        Some(slug) => {
            let ctx = state.registry.get(slug).ok_or_else(|| {
                (
                    StatusCode::NOT_FOUND,
                    format!("project '{slug}' not registered"),
                )
            })?;
            ctx.cache.clone()
        }
        None => state.cache.clone(),
    };

    let project_root = match req.slug.as_deref() {
        Some(slug) => state
            .registry
            .get(slug)
            .map(|ctx| ctx.root.clone())
            .unwrap_or_else(|| state.project_root.clone()),
        None => state.project_root.clone(),
    };

    if req.all.unwrap_or(false) {
        cache.invalidate_all();
    } else if let Some(prefix) = &req.prefix {
        let path = resolve_path(&project_root, prefix)?;
        cache.invalidate_prefix(&path);
    } else if let Some(file) = &req.file {
        let path = resolve_path(&project_root, file)?;
        cache.invalidate_file(&path);
    } else {
        return Err((
            StatusCode::BAD_REQUEST,
            "at least one of 'all', 'prefix', or 'file' must be specified".to_string(),
        ));
    }
    Ok(Json(InvalidateResponse {
        invalidated: true,
        entries_remaining: cache.len(),
    }))
}

// ── Actor Endpoints ─────────────────────────────────────────────────────

#[derive(Serialize)]
struct RegisterResponse {
    token: String,
    actors_connected: usize,
}

/// Register an actor session and receive a bearer token for subsequent calls
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/actors/register",
    auth = "none",
    actors = "agent, developer, ci",
    params = "actor_type:string:required:Actor type (agent|developer|ci|admin); \
              hostname:string:required:Caller hostname (audit trail); pid:number:required:Caller \
              process id (audit trail); project:string:required:Project slug to associate with; \
              role:string:optional:Actor role; preferences:object:optional:Free-form actor \
              preferences",
    tags = "actors, auth"
)]
async fn actor_register(
    State(state): State<AppState>,
    Json(req): Json<RegisterRequest>,
) -> (StatusCode, Json<RegisterResponse>) {
    state.touch_activity();
    #[cfg(feature = "nats")]
    let actor_type = req.actor_type.clone();
    #[cfg(feature = "nats")]
    let project = req.project.clone();
    let token = state.actors.register(req);
    let count = state.actors.count();

    #[cfg(feature = "nats")]
    {
        if let Some(ref nats) = state.nats {
            if let Err(e) = nats
                .publish_actor_registered(&token, &actor_type, &project)
                .await
            {
                tracing::warn!(error = %e, "failed to publish actor.registered event");
            }
        }
    }

    (
        StatusCode::CREATED,
        Json(RegisterResponse {
            token,
            actors_connected: count,
        }),
    )
}

/// Deregister an actor session and invalidate its bearer token
#[ontoref_derive::onto_api(
    method = "DELETE",
    path = "/actors/{token}",
    auth = "none",
    actors = "agent, developer, ci",
    tags = "actors, auth"
)]
async fn actor_deregister(State(state): State<AppState>, Path(token): Path<String>) -> StatusCode {
    state.touch_activity();
    if state.actors.deregister(&token) {
        #[cfg(feature = "nats")]
        {
            if let Some(ref nats) = state.nats {
                if let Err(e) = nats.publish_actor_deregistered(&token, "explicit").await {
                    tracing::warn!(error = %e, "failed to publish actor.deregistered event");
                }
            }
        }
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

/// Extend actor session TTL; prevents the session from expiring due to
/// inactivity
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/actors/{token}/touch",
    auth = "none",
    actors = "agent, developer, ci",
    tags = "actors"
)]
async fn actor_touch(
    State(state): State<AppState>,
    Path(token): Path<String>,
) -> impl IntoResponse {
    state.touch_activity();
    if state.actors.touch(&token) {
        (StatusCode::NO_CONTENT, axum::Json(serde_json::Value::Null)).into_response()
    } else {
        // Return 200 instead of 404 so browsers don't log a console error.
        // The `expired` flag tells the client to re-register silently.
        (
            StatusCode::OK,
            axum::Json(serde_json::json!({ "expired": true })),
        )
            .into_response()
    }
}

#[derive(Deserialize)]
struct ProfileRequest {
    #[serde(default)]
    role: Option<String>,
    #[serde(default)]
    preferences: Option<serde_json::Value>,
}

/// Update actor profile metadata: display name, role, and custom context fields
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/actors/{token}/profile",
    auth = "none",
    actors = "agent, developer",
    tags = "actors"
)]
async fn actor_update_profile(
    State(state): State<AppState>,
    Path(token): Path<String>,
    Json(req): Json<ProfileRequest>,
) -> StatusCode {
    state.touch_activity();
    if state
        .actors
        .update_profile(&token, req.role, req.preferences)
    {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

#[derive(Serialize)]
struct ActorsListResponse {
    actors: Vec<ActorEntry>,
    total: usize,
}

#[derive(Serialize)]
struct ActorEntry {
    token: String,
    #[serde(flatten)]
    session: ActorSessionView,
}

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

/// List all registered actor sessions with their last-seen timestamp and
/// pending notification count
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/actors",
    auth = "viewer",
    actors = "developer, admin",
    params = "project:string:optional:Filter by project slug",
    tags = "actors"
)]
async fn actors_list(
    State(state): State<AppState>,
    Query(query): Query<ActorsQuery>,
) -> Json<ActorsListResponse> {
    state.touch_activity();
    let entries = match query.project {
        Some(ref project) => state.actors.list_for_project(project),
        None => state.actors.list(),
    };
    let total = entries.len();
    let actors = entries
        .into_iter()
        .map(|(token, session)| ActorEntry { token, session })
        .collect();
    Json(ActorsListResponse { actors, total })
}

// ── Notification Endpoints ──────────────────────────────────────────────

#[derive(Deserialize)]
struct PendingQuery {
    token: String,
    project: Option<String>,
    #[serde(default)]
    check_only: bool,
}

#[derive(Serialize)]
struct PendingResponse {
    pending: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    notifications: Option<Vec<NotificationView>>,
}

/// Poll pending notifications for an actor; optionally marks them as seen
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/notifications/pending",
    auth = "none",
    actors = "agent, developer, ci",
    params = "token:string:required:Actor bearer token; project:string:optional:Project slug \
              filter; check_only:bool:default=false:Return count without marking seen",
    tags = "notifications"
)]
async fn notifications_pending(
    State(state): State<AppState>,
    Query(query): Query<PendingQuery>,
) -> impl IntoResponse {
    state.touch_activity();

    if state.actors.get_and_touch(&query.token).is_none() {
        return (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"error": "unknown or expired actor token"})),
        )
            .into_response();
    }

    let project = query
        .project
        .unwrap_or_else(|| state.default_project_name());

    if query.check_only {
        let count = state.notifications.pending_count(&project, &query.token);
        Json(PendingResponse {
            pending: count,
            notifications: None,
        })
        .into_response()
    } else {
        let notifications = state.notifications.pending(&project, &query.token);
        let count = notifications.len();
        Json(PendingResponse {
            pending: count,
            notifications: Some(notifications),
        })
        .into_response()
    }
}

#[derive(Serialize)]
struct AckResponse {
    acknowledged: usize,
}

/// Acknowledge one or more notifications; removes them from the pending queue
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/notifications/ack",
    auth = "none",
    actors = "agent, developer, ci",
    params = "token:string:required:Actor bearer token; ids:string:required:Comma-separated \
              notification ids to acknowledge",
    tags = "notifications"
)]
async fn notifications_ack(
    State(state): State<AppState>,
    Json(req): Json<AckRequest>,
) -> std::result::Result<Json<AckResponse>, (StatusCode, String)> {
    state.touch_activity();
    state.actors.touch(&req.token);

    let project = req.project.unwrap_or_else(|| state.default_project_name());

    let count = if req.all {
        let acked = state.notifications.ack_all(&project, &req.token);
        state.actors.clear_pending(&req.token);
        acked
    } else if let Some(id) = req.notification_id {
        if state.notifications.ack_one(&project, &req.token, id) {
            1
        } else {
            0
        }
    } else {
        return Err((
            StatusCode::BAD_REQUEST,
            "either 'all: true' or 'notification_id' must be specified".to_string(),
        ));
    };

    Ok(Json(AckResponse {
        acknowledged: count,
    }))
}

#[derive(Serialize)]
struct EmitResponse {
    id: u64,
    project: String,
}

/// Emit a free-form custom notification — delivered to SSE subscribers and
/// stored in the pending queue like any other notification. `EmitRequest`
/// existed as a declared shape with no route (dead surface) until ⛓G2/A5
/// (audit-harness plan): `onre audit`'s notification sink is this route's
/// first real producer, publishing a verdict's directive (Stop/Rectify/
/// Continue) as the notification payload when the daemon is reachable.
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/notifications/emit",
    auth = "none",
    actors = "agent, developer, ci",
    params = "token:string:required:Actor bearer token; kind:string:required:Free-form \
              notification kind; title:string:required:Human-readable title; \
              target_project:string:optional:Project slug override (defaults to primary); \
              payload:object:optional:Free-form JSON payload",
    tags = "notifications"
)]
async fn notifications_emit(
    State(state): State<AppState>,
    Json(body): Json<EmitRequest>,
) -> impl IntoResponse {
    state.touch_activity();

    if state.actors.get_and_touch(&body.token).is_none() {
        return (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"error": "unknown or expired actor token"})),
        )
            .into_response();
    }

    let project = body
        .target_project
        .clone()
        .unwrap_or_else(|| state.default_project_name());

    let proj = state.registry.get(&project);
    let notifications = proj
        .as_ref()
        .map(|p| &p.notifications)
        .unwrap_or(&state.notifications);

    let id = notifications.push_custom(
        &project,
        body.kind.clone(),
        body.title.clone(),
        body.payload.clone(),
        body.source_actor.clone(),
        None,
    );

    (StatusCode::ACCEPTED, Json(EmitResponse { id, project })).into_response()
}

// ── SSE Notification Stream ───────────────────────────────────────────────

/// Query params for `GET /notifications/stream`.
#[derive(Deserialize)]
struct StreamQuery {
    /// Actor token — used to scope the stream to this actor's project.
    token: String,
    /// Optional project slug override. Defaults to the actor's project.
    project: Option<String>,
}

/// SSE push stream: actor subscribes once and receives notification events as
/// they occur
///
/// Each event is a JSON-serialized `NotificationView`. Clients receive push
/// notifications without polling. Reconnects automatically pick up new events
/// (no replay of missed events — use `/notifications/pending` for that).
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/notifications/stream",
    auth = "none",
    actors = "agent, developer",
    params = "token:string:required:Actor bearer token; project:string:optional:Project slug \
              filter",
    tags = "notifications, sse"
)]
async fn notifications_stream(
    State(state): State<AppState>,
    Query(params): Query<StreamQuery>,
) -> axum::response::Response {
    use axum::response::sse::{Event, KeepAlive};
    use axum::response::Sse;
    use tokio_stream::wrappers::BroadcastStream;
    use tokio_stream::StreamExt;

    let session = match state.actors.get_and_touch(&params.token) {
        Some(s) => s,
        None => {
            return (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({"error": "unknown or expired actor token"})),
            )
                .into_response();
        }
    };

    // Resolve which notification store to subscribe to.
    let notifications = if let Some(slug) = &params.project {
        state
            .registry
            .get(slug)
            .map(|ctx| Arc::clone(&ctx.notifications))
            .unwrap_or_else(|| Arc::clone(&state.notifications))
    } else {
        // Derive project from the actor session — reuse the snapshot from
        // get_and_touch.
        let actor_project = Some(session.project.clone());
        actor_project
            .as_deref()
            .and_then(|p| state.registry.get(p))
            .map(|ctx| Arc::clone(&ctx.notifications))
            .unwrap_or_else(|| Arc::clone(&state.notifications))
    };

    let rx = notifications.subscribe();
    let stream = BroadcastStream::new(rx).filter_map(|result| match result {
        Ok(view) => {
            let json = serde_json::to_string(&view).unwrap_or_default();
            Some(Ok::<_, std::convert::Infallible>(
                Event::default().event("notification").data(json),
            ))
        }
        // Lagged — receiver missed events; client should call /notifications/pending to catch up.
        Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
            let msg = format!("{{\"lagged\":{n}}}");
            Some(Ok(Event::default().event("lagged").data(msg)))
        }
    });

    Sse::new(stream)
        .keep_alive(KeepAlive::default())
        .into_response()
}

// ── Git Hook — Ontology Changed ───────────────────────────────────────────

/// Body for `POST /ontology/changed` — sent by a git hook after NCL files
/// change.
#[derive(Deserialize)]
struct OntologyChangedRequest {
    /// Actor token that caused the change (from `ONTOREF_TOKEN` env var).
    token: String,
    /// Relative file paths that changed (e.g. `[".ontology/core.ncl"]`).
    files: Vec<String>,
    /// Project slug. Defaults to the actor's registered project.
    #[serde(default)]
    project: Option<String>,
}

/// Git hook endpoint: actor signs a file-change event it caused to suppress
/// self-notification
///
/// Called by git hooks (post-merge, post-commit) so the daemon knows *who*
/// caused the change. Creates a notification with `source_actor` set, enabling
/// multi-actor coordination UIs to display attribution.
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/ontology/changed",
    auth = "viewer",
    actors = "developer, ci",
    params = "token:string:required:Actor bearer token; files:string:required:JSON array of \
              changed file paths",
    tags = "ontology, notifications"
)]
async fn ontology_changed(
    State(state): State<AppState>,
    Json(req): Json<OntologyChangedRequest>,
) -> impl IntoResponse {
    // Reject unknown tokens — git hook attribution requires a registered actor.
    let actor_project = match state.actors.get(&req.token) {
        Some(s) => s.project.clone(),
        None => {
            return (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({"error": "unknown or expired actor token"})),
            )
                .into_response();
        }
    };

    // Resolve project and notification store from the actor token.
    let (project, notifications) = {
        let project = match &req.project {
            // If the caller explicitly names a project, it must match the
            // token's registered project — prevents cross-project injection.
            Some(req_project) if req_project != &actor_project => {
                return (
                    StatusCode::FORBIDDEN,
                    Json(serde_json::json!({
                        "error": format!(
                            "token is registered to project '{}', not '{req_project}'",
                            actor_project
                        )
                    })),
                )
                    .into_response();
            }
            Some(req_project) => req_project.clone(),
            None => actor_project,
        };
        let store = state
            .registry
            .get(&project)
            .map(|ctx| Arc::clone(&ctx.notifications))
            .unwrap_or_else(|| Arc::clone(&state.notifications));
        (project, store)
    };

    if req.files.is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": "files must not be empty"})),
        )
            .into_response();
    }

    let ids = notifications.push(&project, req.files, Some(req.token.clone()));

    // Increment pending for all actors in the project except the source actor.
    if !ids.is_empty() {
        for token in state.actors.tokens_for_project(&project) {
            if token != req.token {
                state.actors.increment_pending(&token);
            }
        }
    }

    (
        StatusCode::OK,
        Json(serde_json::json!({
            "project": project,
            "notifications_created": ids.len(),
            "source_actor": req.token,
        })),
    )
        .into_response()
}

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

#[derive(Deserialize)]
struct SearchQuery {
    q: Option<String>,
    #[cfg(feature = "ui")]
    slug: Option<String>,
}

#[derive(Serialize)]
struct SearchResponse {
    query: String,
    results: Vec<crate::search::SearchResult>,
}

/// Full-text search over ontology nodes, ADRs, practices and Q&A entries
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/search",
    auth = "none",
    actors = "agent, developer",
    params = "q:string:required:Search query string; slug:string:optional:Project slug (ui \
              feature only)",
    tags = "search"
)]
async fn search(
    State(state): State<AppState>,
    Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
    let q = params.q.as_deref().unwrap_or("").trim().to_string();
    if q.is_empty() {
        return Json(SearchResponse {
            query: q,
            results: vec![],
        });
    }

    // In multi-project mode, resolve context from slug; fall back to primary
    // project.
    #[cfg(feature = "ui")]
    let results = {
        if let Some(slug) = params.slug.as_deref() {
            if let Some(ctx) = state.registry.get(slug) {
                crate::search::search_project(&ctx.root, &ctx.cache, ctx.import_path.as_deref(), &q)
                    .await
            } else {
                vec![]
            }
        } else {
            crate::search::search_project(
                &state.project_root,
                &state.cache,
                state.nickel_import_path.as_deref(),
                &q,
            )
            .await
        }
    };

    #[cfg(not(feature = "ui"))]
    let results = crate::search::search_project(
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        &q,
    )
    .await;

    Json(SearchResponse { query: q, results })
}

// ── Describe Endpoints ───────────────────────────────────────────────────

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

#[derive(Deserialize)]
struct ActorInitQuery {
    actor: Option<String>,
    slug: Option<String>,
}

#[derive(Deserialize)]
struct GuidesQuery {
    slug: Option<String>,
    actor: Option<String>,
}

/// Resolve project context from an optional slug.
/// Falls back to the primary project when slug is absent or not found in
/// registry.
#[cfg(feature = "ui")]
fn resolve_project_ctx(
    state: &AppState,
    slug: Option<&str>,
) -> (PathBuf, Arc<NclCache>, Option<String>) {
    if let Some(s) = slug {
        if let Some(ctx) = state.registry.get(s) {
            return (ctx.root.clone(), ctx.cache.clone(), ctx.import_path.clone());
        }
    }
    (
        state.project_root.clone(),
        state.cache.clone(),
        state.nickel_import_path.clone(),
    )
}

#[cfg(not(feature = "ui"))]
fn resolve_project_ctx(
    state: &AppState,
    _slug: Option<&str>,
) -> (PathBuf, Arc<NclCache>, Option<String>) {
    (
        state.project_root.clone(),
        state.cache.clone(),
        state.nickel_import_path.clone(),
    )
}

/// Project self-description: identity, axioms, tensions, practices, gates,
/// ADRs, dimensions
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/describe/project",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "describe, ontology"
)]
async fn describe_project(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let path = ontoref_ontology::layout::resolve_section(&root, "ontology/core.ncl");
    if !path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "core.ncl not found" })),
        );
    }
    match cache.export(&path, import_path.as_deref()).await {
        Ok((data, _)) => (StatusCode::OK, Json(data)),
        Err(e) => {
            error!(path = %path.display(), error = %e, "describe_project export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

/// Cross-project connection declarations: upstream, downstream, peers with
/// addressing
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/describe/connections",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "describe, federation"
)]
async fn describe_connections(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let path = ontoref_ontology::layout::resolve_section(&root, "ontology/connections.ncl");
    if !path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "connections.ncl not found" })),
        );
    }
    match cache.export(&path, import_path.as_deref()).await {
        Ok((data, _)) => (StatusCode::OK, Json(data)),
        Err(e) => {
            error!(path = %path.display(), error = %e, "describe_connections export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

/// Execute typed ADR constraint checks and return per-constraint pass/fail
/// results
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/validate/adrs",
    auth = "viewer",
    actors = "developer, ci, agent",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "validate, adrs"
)]
async fn validate_adrs(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, _cache, _import_path) = resolve_project_ctx(&state, q.slug.as_deref());

    let output = match tokio::process::Command::new("nu")
        .args([
            "--no-config-file",
            "-c",
            "use reflection/modules/validate.nu *; validate check-all --fmt json",
        ])
        .current_dir(&root)
        .output()
        .await
    {
        Ok(o) => o,
        Err(e) => {
            error!(error = %e, "validate_adrs: failed to spawn nu");
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": format!("spawn failed: {e}") })),
            );
        }
    };

    let stdout = String::from_utf8_lossy(&output.stdout);
    match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
        Ok(v) => (StatusCode::OK, Json(v)),
        Err(e) => {
            let stderr = String::from_utf8_lossy(&output.stderr);
            error!(error = %e, stderr = %stderr, "validate_adrs: nu output is not valid JSON");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({
                    "error":  format!("invalid JSON from validate: {e}"),
                    "stderr": stderr.trim(),
                })),
            )
        }
    }
}

#[derive(Deserialize)]
struct ImpactQuery {
    slug: Option<String>,
    node: String,
    #[serde(default = "default_depth")]
    depth: u32,
    #[serde(default)]
    include_external: bool,
}

fn default_depth() -> u32 {
    2
}

/// BFS impact graph from an ontology node; optionally traverses cross-project
/// connections
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/graph/impact",
    auth = "none",
    actors = "agent, developer",
    params = "node:string:required:Ontology node id to start from; depth:u32:default=2:Max BFS \
              hops (capped at 5); include_external:bool:default=false:Follow connections.ncl to \
              external projects; slug:string:optional:Project slug (defaults to primary)",
    tags = "graph, federation"
)]
async fn graph_impact(
    State(state): State<AppState>,
    Query(q): Query<ImpactQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let effective_slug = q
        .slug
        .clone()
        .unwrap_or_else(|| state.registry.primary_slug().to_owned());
    let fed = crate::federation::FederatedQuery::new(Arc::clone(&state.registry));
    let impacts = fed
        .impact_graph(&effective_slug, &q.node, q.depth, q.include_external)
        .await;
    (
        StatusCode::OK,
        Json(serde_json::json!({
            "slug":             effective_slug,
            "node":             q.node,
            "depth":            q.depth,
            "include_external": q.include_external,
            "impacts":          impacts,
        })),
    )
}

/// Resolve a single ontology node by id from the local cache (used by
/// federation)
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/graph/node/{id}",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "graph, federation"
)]
async fn graph_node(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let core_path = ontoref_ontology::layout::resolve_section(&root, "ontology/core.ncl");
    if !core_path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "core.ncl not found" })),
        );
    }
    match cache.export(&core_path, import_path.as_deref()).await {
        Ok((json, _)) => {
            let node = json
                .get("nodes")
                .and_then(|n| n.as_array())
                .and_then(|nodes| {
                    nodes
                        .iter()
                        .find(|n| n.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))
                        .cloned()
                });
            match node {
                Some(n) => (StatusCode::OK, Json(n)),
                None => (
                    StatusCode::NOT_FOUND,
                    Json(serde_json::json!({ "error": format!("node '{}' not found", id) })),
                ),
            }
        }
        Err(e) => {
            error!(node = %id, error = %e, "graph_node: core export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

/// Complete operational context for an actor: identity, axioms, practices,
/// constraints, gate state, modes, actor policy, connections, content assets
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/describe/guides",
    auth = "none",
    actors = "agent, developer, ci",
    params = "slug:string:optional:Project slug (defaults to primary); \
              actor:string:optional:Actor context filters the policy (agent|developer|ci|admin)",
    tags = "describe, guides"
)]
async fn describe_guides(
    State(state): State<AppState>,
    Query(q): Query<GuidesQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, _cache, _import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let actor = q.actor.as_deref().unwrap_or("developer");

    let nu_cmd = format!(
        "use reflection/modules/describe.nu *; describe guides --actor {} --fmt json",
        actor,
    );
    let output = match tokio::process::Command::new("nu")
        .args(["--no-config-file", "-c", &nu_cmd])
        .current_dir(&root)
        .output()
        .await
    {
        Ok(o) => o,
        Err(e) => {
            error!(error = %e, "describe_guides: failed to spawn nu");
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": format!("spawn failed: {e}") })),
            );
        }
    };

    let stdout = String::from_utf8_lossy(&output.stdout);
    match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
        Ok(v) => (StatusCode::OK, Json(v)),
        Err(e) => {
            let stderr = String::from_utf8_lossy(&output.stderr);
            error!(error = %e, stderr = %stderr, "describe_guides: nu output is not valid JSON");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({
                    "error":  format!("invalid JSON from describe guides: {e}"),
                    "stderr": stderr.trim(),
                })),
            )
        }
    }
}

/// Available reflection modes, just recipes, Claude capabilities and CI tools
/// for the project
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/describe/capabilities",
    auth = "none",
    actors = "agent, developer, ci",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "describe"
)]
async fn describe_capabilities(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());

    let modes_dir = ontoref_ontology::layout::resolve_section(&root, "reflection/modes");
    let adrs_dir = ontoref_ontology::layout::resolve_section(&root, "adrs");
    let forms_dir = ontoref_ontology::layout::resolve_section(&root, "reflection/forms");

    // Modes: export each NCL for id + trigger
    let mut modes: 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 stem = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            match cache.export(&path, import_path.as_deref()).await {
                Ok((json, _)) => {
                    let id = json
                        .get("id")
                        .and_then(|v| v.as_str())
                        .unwrap_or(&stem)
                        .to_string();
                    let trigger = json
                        .get("trigger")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    modes.push(serde_json::json!({ "id": id, "trigger": trigger }));
                }
                Err(e) => {
                    warn!(path = %path.display(), error = %e, "capabilities: mode export failed");
                    modes.push(serde_json::json!({ "id": stem, "trigger": "" }));
                }
            }
        }
    }
    modes.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());

    // ADRs: count only
    let adr_count = std::fs::read_dir(&adrs_dir)
        .map(|rd| {
            rd.flatten()
                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
                .count()
        })
        .unwrap_or(0);

    // Forms: list file stems
    let mut forms: Vec<String> = std::fs::read_dir(&forms_dir)
        .map(|rd| {
            rd.flatten()
                .filter_map(|e| {
                    let p = e.path();
                    if p.extension().and_then(|x| x.to_str()) != Some("ncl") {
                        return None;
                    }
                    p.file_stem().and_then(|s| s.to_str()).map(str::to_string)
                })
                .collect()
        })
        .unwrap_or_default();
    forms.sort();

    let mode_count = modes.len();
    let form_count = forms.len();

    (
        StatusCode::OK,
        Json(serde_json::json!({
            "modes":      modes,
            "adrs":       adr_count,
            "forms":      forms,
            "mode_count": mode_count,
            "adr_count":  adr_count,
            "form_count": form_count,
        })),
    )
}

/// Minimal onboarding payload for a new actor session: what to register as and
/// what to do first
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/describe/actor-init",
    auth = "none",
    actors = "agent",
    params = "actor:string:optional:Actor type to onboard as; slug:string:optional:Project slug",
    tags = "describe, actors"
)]
async fn describe_actor_init(
    State(state): State<AppState>,
    Query(q): Query<ActorInitQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let actor = q.actor.as_deref().unwrap_or("agent");
    let config_path = root.join(".ontoref").join("config.ncl");

    if !config_path.exists() {
        return (
            StatusCode::OK,
            Json(serde_json::json!({ "mode": "", "auto_run": false })),
        );
    }

    match cache.export(&config_path, import_path.as_deref()).await {
        Ok((json, _)) => {
            let entry = json
                .get("actor_init")
                .and_then(|v| v.as_array())
                .and_then(|arr| {
                    arr.iter()
                        .find(|e| e.get("actor").and_then(|v| v.as_str()) == Some(actor))
                })
                .cloned();

            let result = match entry {
                Some(e) => {
                    let mode = e
                        .get("mode")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    let auto_run = e.get("auto_run").and_then(|v| v.as_bool()).unwrap_or(false);
                    serde_json::json!({ "mode": mode, "auto_run": auto_run })
                }
                None => serde_json::json!({ "mode": "", "auto_run": false }),
            };
            (StatusCode::OK, Json(result))
        }
        Err(e) => {
            warn!(path = %config_path.display(), error = %e, "describe_actor_init export failed");
            (
                StatusCode::OK,
                Json(serde_json::json!({ "mode": "", "auto_run": false })),
            )
        }
    }
}

// ── ADR read endpoint ────────────────────────────────────────────────────────

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

/// Read a single ADR by id, exported from NCL as structured JSON
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/adr/{id}",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "adrs"
)]
async fn get_adr(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Query(q): Query<AdrQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let adrs_dir = ontoref_ontology::layout::resolve_section(&root, "adrs");

    let entries = match std::fs::read_dir(&adrs_dir) {
        Ok(e) => e,
        Err(_) => {
            return (
                StatusCode::NOT_FOUND,
                Json(serde_json::json!({ "error": "adrs directory not found" })),
            );
        }
    };

    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();
        if !stem.contains(id.as_str()) {
            continue;
        }
        return match cache.export(&path, import_path.as_deref()).await {
            Ok((v, _)) => (StatusCode::OK, Json(v)),
            Err(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            ),
        };
    }

    (
        StatusCode::NOT_FOUND,
        Json(serde_json::json!({ "error": format!("ADR '{}' not found", id) })),
    )
}

// ── File content endpoint
// ─────────────────────────────────────────────────────

#[derive(serde::Deserialize)]
struct FileQuery {
    path: String,
    slug: Option<String>,
}

/// Read a project-relative file and return its content as text (for UI artifact
/// links)
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/file",
    auth = "none",
    actors = "developer",
    params = "path:string:required:Project-relative file path, slug:string:optional:Project slug",
    tags = "graph"
)]
async fn get_file_content(
    State(state): State<AppState>,
    Query(q): Query<FileQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, _, _) = resolve_project_ctx(&state, q.slug.as_deref());

    // Prevent path traversal — canonicalize both root and requested path.
    let Ok(canonical_root) = root.canonicalize() else {
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({ "error": "cannot resolve project root" })),
        );
    };
    let requested = canonical_root.join(q.path.trim_start_matches('/'));
    let Ok(canonical_req) = requested.canonicalize() else {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "file not found" })),
        );
    };
    if !canonical_req.starts_with(&canonical_root) {
        return (
            StatusCode::FORBIDDEN,
            Json(serde_json::json!({ "error": "path outside project root" })),
        );
    }

    let ext = canonical_req
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("");
    let lang = match ext {
        "rs" => "rust",
        "toml" => "toml",
        "ncl" => "nickel",
        "nu" => "nushell",
        "md" => "markdown",
        "json" => "json",
        "html" => "html",
        _ => "text",
    };

    match std::fs::read_to_string(&canonical_req) {
        Ok(content) => (
            StatusCode::OK,
            Json(serde_json::json!({ "content": content, "lang": lang, "path": q.path })),
        ),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({ "error": e.to_string() })),
        ),
    }
}

// ── Ontology extension endpoints ─────────────────────────────────────────────

const CORE_FILES: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"];

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

/// List available ontology extension files beyond core, state, gate, manifest
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/ontology",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "ontology"
)]
async fn list_ontology_extensions(
    State(state): State<AppState>,
    Query(q): Query<OntologyQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, _, _) = resolve_project_ctx(&state, q.slug.as_deref());
    let ontology_dir = ontoref_ontology::layout::resolve_section(&root, "ontology");

    let entries = match std::fs::read_dir(&ontology_dir) {
        Ok(e) => e,
        Err(_) => {
            return (
                StatusCode::OK,
                Json(serde_json::json!({ "extensions": [] })),
            );
        }
    };

    let mut extensions: Vec<serde_json::Value> = entries
        .flatten()
        .filter_map(|e| {
            let path = e.path();
            if path.extension().and_then(|x| x.to_str()) != Some("ncl") {
                return None;
            }
            let name = path.file_name()?.to_str()?.to_string();
            if CORE_FILES.contains(&name.as_str()) {
                return None;
            }
            let stem = path.file_stem()?.to_str()?.to_string();
            Some(serde_json::json!({ "file": name, "id": stem }))
        })
        .collect();

    extensions.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());
    (
        StatusCode::OK,
        Json(serde_json::json!({ "extensions": extensions })),
    )
}

/// Export a specific ontology extension file to JSON
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/ontology/{file}",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "ontology"
)]
async fn get_ontology_extension(
    State(state): State<AppState>,
    Path(file): Path<String>,
    Query(q): Query<OntologyQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());

    // Reject traversal attempts and core files — they have dedicated endpoints.
    if file.contains('/') || file.contains("..") || CORE_FILES.contains(&file.as_str()) {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({ "error": "invalid file name" })),
        );
    }

    let file = if file.ends_with(".ncl") {
        file
    } else {
        format!("{file}.ncl")
    };

    let path = ontoref_ontology::layout::resolve_section(&root, &format!("ontology/{file}"));
    if !path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(
                serde_json::json!({ "error": format!("ontology extension '{}' not found", file) }),
            ),
        );
    }

    match cache.export(&path, import_path.as_deref()).await {
        Ok((v, _)) => (StatusCode::OK, Json(v)),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({ "error": e.to_string() })),
        ),
    }
}

/// Export the project backlog as structured JSON from reflection/backlog.ncl
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/backlog-json",
    auth = "viewer",
    actors = "developer, agent",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "backlog"
)]
async fn backlog_json(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let backlog_path = ontoref_ontology::layout::resolve_section(&root, "reflection/backlog.ncl");

    if !backlog_path.exists() {
        return (StatusCode::OK, Json(serde_json::json!([])));
    }

    match cache.export(&backlog_path, import_path.as_deref()).await {
        Ok((json, _)) => {
            let items = json
                .get("items")
                .and_then(|v| v.as_array())
                .cloned()
                .unwrap_or_default();
            (StatusCode::OK, Json(serde_json::Value::Array(items)))
        }
        Err(e) => {
            error!(path = %backlog_path.display(), error = %e, "backlog_json export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

// ── Backlog propose-status ──────────────────────────────────────────────────

#[derive(serde::Deserialize)]
struct BacklogProposeRequest {
    pub id: String,
    pub proposed_status: String,
    #[serde(default)]
    pub proposed_by: String,
    /// Target project slug (defaults to primary).
    #[serde(default)]
    pub slug: Option<String>,
}

/// Propose a status change for a backlog item. Creates a backlog_review
/// notification that requires admin approval via the UI or CLI.
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/backlog/propose-status",
    auth = "viewer",
    actors = "agent, developer",
    params = "id:string:required:Backlog item id (e.g. bl-001) | \
              proposed_status:string:required:Target status (Open/InProgress/Done/Cancelled) | \
              proposed_by:string:optional:Actor label shown in the notification | \
              slug:string:optional:Project slug (defaults to primary)",
    tags = "backlog"
)]
async fn backlog_propose_status(
    State(state): State<AppState>,
    Json(body): Json<BacklogProposeRequest>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, body.slug.as_deref());
    let slug = body
        .slug
        .clone()
        .unwrap_or_else(|| state.registry.primary_slug().to_string());

    let backlog_path = ontoref_ontology::layout::resolve_section(&root, "reflection/backlog.ncl");
    let items = if backlog_path.exists() {
        match cache.export(&backlog_path, import_path.as_deref()).await {
            Ok((json, _)) => json
                .get("items")
                .and_then(|v| v.as_array())
                .cloned()
                .unwrap_or_default(),
            Err(_) => vec![],
        }
    } else {
        vec![]
    };

    let item = items
        .iter()
        .find(|it| it.get("id").and_then(|v| v.as_str()) == Some(body.id.as_str()));

    let item_title = item
        .and_then(|it| it.get("title").and_then(|v| v.as_str()))
        .unwrap_or(body.id.as_str())
        .to_string();
    let current_status = item
        .and_then(|it| it.get("status").and_then(|v| v.as_str()))
        .unwrap_or("Unknown")
        .to_string();

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

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

    let proj = state.registry.get(&slug);
    let notifications = proj
        .as_ref()
        .map(|p| &p.notifications)
        .unwrap_or(&state.notifications);

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

    (
        StatusCode::ACCEPTED,
        Json(serde_json::json!({
            "status": "proposed",
            "item_id": body.id,
            "proposed_status": body.proposed_status,
        })),
    )
}

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

/// Export the Q&A knowledge store as structured JSON from reflection/qa.ncl
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/qa-json",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "qa"
)]
async fn qa_json(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let qa_path = ontoref_ontology::layout::resolve_section(&root, "reflection/qa.ncl");

    if !qa_path.exists() {
        return (StatusCode::OK, Json(serde_json::json!([])));
    }

    match cache.export(&qa_path, import_path.as_deref()).await {
        Ok((json, _)) => {
            let entries = json
                .get("entries")
                .and_then(|v| v.as_array())
                .cloned()
                .unwrap_or_default();
            (StatusCode::OK, Json(serde_json::Value::Array(entries)))
        }
        Err(e) => {
            error!(path = %qa_path.display(), error = %e, "qa_json export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

// ── Project Registry Endpoints ───────────────────────────────────────────────

#[derive(Serialize)]
struct ProjectView {
    slug: String,
    root: String,
    push_only: bool,
    remote_url: String,
    auth_enabled: bool,
    /// Monotonically increasing counter — incremented after each successful
    /// seed. Clients compare this value to detect stale local state.
    ontology_version: u64,
}

/// List all registered projects with slug, root, push_only flag and import path
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/projects",
    auth = "admin",
    actors = "admin",
    tags = "projects, registry"
)]
async fn projects_list(State(state): State<AppState>) -> impl IntoResponse {
    use std::sync::atomic::Ordering;
    state.touch_activity();
    let projects: Vec<ProjectView> = state
        .registry
        .all()
        .into_iter()
        .map(|ctx| ProjectView {
            slug: ctx.slug.clone(),
            root: ctx.root.display().to_string(),
            push_only: ctx.push_only,
            remote_url: ctx.remote_url.clone(),
            auth_enabled: ctx.auth_enabled(),
            ontology_version: ctx.ontology_version.load(Ordering::Acquire),
        })
        .collect();
    Json(serde_json::json!({ "projects": projects, "total": projects.len() }))
}

/// Validate that a project slug contains only alphanumeric characters, `-`, or
/// `_`. Rejects slugs with `/`, `.`, null bytes, or other path-significant
/// characters.
fn validate_slug(slug: &str) -> std::result::Result<(), (StatusCode, String)> {
    if slug.is_empty() {
        return Err((StatusCode::BAD_REQUEST, "slug must not be empty".into()));
    }
    if slug.len() > 64 {
        return Err((
            StatusCode::BAD_REQUEST,
            "slug must not exceed 64 characters".into(),
        ));
    }
    let valid = slug
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
    if !valid {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("slug '{slug}' contains invalid characters — only [a-zA-Z0-9_-] are allowed"),
        ));
    }
    Ok(())
}

/// Register a new project at runtime without daemon restart
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/projects",
    auth = "admin",
    actors = "admin",
    tags = "projects, registry"
)]
async fn project_add(
    State(state): State<AppState>,
    Json(entry): Json<crate::registry::RegistryEntry>,
) -> impl IntoResponse {
    state.touch_activity();

    if let Err(e) = validate_slug(&entry.slug) {
        return e.into_response();
    }

    let registry = &state.registry;

    let slug = entry.slug.clone();
    if let Err(e) = registry.add_project(entry) {
        return (
            StatusCode::CONFLICT,
            Json(serde_json::json!({"error": e.to_string()})),
        )
            .into_response();
    }

    // Notify the runtime watcher task to start watching the new project.
    if let Some(ctx) = registry.get(&slug) {
        if let Some(ref tx) = state.new_project_tx {
            let _ = tx.send(ctx);
        }
    }

    (
        StatusCode::CREATED,
        Json(serde_json::json!({"slug": slug, "registered": true})),
    )
        .into_response()
}

/// Deregister a project and stop its file watcher
#[ontoref_derive::onto_api(
    method = "DELETE",
    path = "/projects/{slug}",
    auth = "admin",
    actors = "admin",
    tags = "projects, registry"
)]
async fn project_delete(
    State(state): State<AppState>,
    Path(slug): Path<String>,
) -> impl IntoResponse {
    state.touch_activity();
    let registry = &state.registry;
    if registry.get(&slug).is_none() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("project '{slug}' not registered")})),
        )
            .into_response();
    }
    registry.remove_project(&slug);

    // Abort and drop the file watcher for this project so the OS watch
    // handle is released and the debounce task doesn't keep running.
    if let Some(watcher) = state.watcher_map.lock().await.remove(&slug) {
        watcher.abort();
    }

    StatusCode::NO_CONTENT.into_response()
}

// ── Project Key Rotation ─────────────────────────────────────────────────────

#[derive(Deserialize)]
struct UpdateKeysRequest {
    keys: Vec<crate::registry::KeyEntry>,
}

#[derive(Serialize)]
struct UpdateKeysResponse {
    slug: String,
    keys_active: usize,
}

/// Hot-rotate credentials for a project; invalidates all existing actor and UI
/// sessions
///
/// Auth:
/// - If the project has existing keys, requires `Authorization: Bearer
///   <password>` with **admin** role. This prevents a viewer or an attacker
///   from rotating keys.
/// - If the project has no keys yet (bootstrap case), the request is accepted
///   without credentials — the daemon is loopback-only, so OS-level access
///   controls apply.
#[ontoref_derive::onto_api(
    method = "PUT",
    path = "/projects/{slug}/keys",
    auth = "admin",
    actors = "admin",
    tags = "projects, auth"
)]
async fn project_update_keys(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    Path(slug): Path<String>,
    Json(req): Json<UpdateKeysRequest>,
) -> impl IntoResponse {
    let registry = &state.registry;

    let ctx = match registry.get(&slug) {
        Some(c) => c,
        None => {
            return (
                StatusCode::NOT_FOUND,
                Json(serde_json::json!({"error": format!("project '{slug}' not registered")})),
            )
                .into_response();
        }
    };

    // If the project already has keys, require admin credentials before allowing
    // rotation.
    if ctx.auth_enabled() {
        let bearer = headers
            .get(axum::http::header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .map(str::trim)
            .filter(|s| !s.is_empty());

        match bearer {
            None => {
                return (
                    StatusCode::UNAUTHORIZED,
                    Json(serde_json::json!({"error": "Authorization: Bearer <password> required"})),
                )
                    .into_response();
            }
            Some(password) => match ctx.verify_key(password).map(|m| m.role) {
                Some(crate::registry::Role::Admin) => {}
                Some(crate::registry::Role::Viewer) => {
                    return (
                        StatusCode::FORBIDDEN,
                        Json(serde_json::json!({"error": "admin role required to rotate keys"})),
                    )
                        .into_response();
                }
                None => {
                    return (
                        StatusCode::UNAUTHORIZED,
                        Json(serde_json::json!({"error": "invalid credentials"})),
                    )
                        .into_response();
                }
            },
        }
    }

    let keys_active = ctx.set_keys(req.keys);

    // Invalidate all active actor sessions.
    let evicted_actors = ctx.actors.deregister_all_for_project(&slug);
    if evicted_actors > 0 {
        tracing::info!(slug = %slug, evicted = evicted_actors, "actor sessions invalidated after key rotation");
    }

    // Invalidate all UI/API sessions for this project — they authenticated
    // against the old key set and must re-authenticate after rotation.
    #[cfg(feature = "ui")]
    {
        let evicted_sessions = state.sessions.revoke_all_for_slug(&slug);
        if evicted_sessions > 0 {
            tracing::info!(slug = %slug, evicted = evicted_sessions, "UI sessions invalidated after key rotation");
        }
    }

    // Persist keys to keys-overlay.json so they survive daemon restart.
    if let Some(ref config_dir) = state.config_dir {
        persist_keys_overlay(config_dir, &state.registry);
    }

    (
        StatusCode::OK,
        Json(UpdateKeysResponse { slug, keys_active }),
    )
        .into_response()
}

/// Per-file ontology change counters for a project; incremented on every cache
/// invalidation
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/projects/{slug}/ontology/versions",
    auth = "none",
    actors = "agent, developer",
    tags = "projects, ontology, cache"
)]
/// Return per-file ontology version counters for a registered project.
///
/// Each counter is incremented every time the watcher invalidates that specific
/// file in the NCL cache. Clients can snapshot and compare between polls to
/// detect which individual files changed, without re-fetching all content.
async fn project_file_versions(
    State(state): State<AppState>,
    Path(slug): Path<String>,
) -> impl IntoResponse {
    let Some(ctx) = state.registry.get(&slug) else {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("project '{slug}' not registered")})),
        )
            .into_response();
    };

    let versions: std::collections::BTreeMap<String, u64> = ctx
        .file_versions
        .iter()
        .map(|r| (r.key().display().to_string(), *r.value()))
        .collect();

    Json(serde_json::json!({
        "slug": slug,
        "global_version": ctx.ontology_version.load(std::sync::atomic::Ordering::Acquire),
        "files": versions,
    }))
    .into_response()
}

// ── Config surface endpoints
// ──────────────────────────────────────────────────

/// Resolve a project context or return 404.
macro_rules! require_project {
    ($state:expr, $slug:expr) => {
        match $state.registry.get(&$slug) {
            Some(ctx) => ctx,
            None => {
                return (
                    StatusCode::NOT_FOUND,
                    Json(serde_json::json!({"error": format!("project '{}' not registered", $slug)})),
                )
                    .into_response()
            }
        }
    };
}

/// Resolve the config surface for a project or return 404.
macro_rules! require_config_surface {
    ($ctx:expr, $slug:expr) => {
        match &$ctx.config_surface {
            Some(s) => s.clone(),
            None => {
                return (
                    StatusCode::NOT_FOUND,
                    Json(serde_json::json!({"error": format!("project '{}' has no config_surface in manifest.ncl", $slug)})),
                )
                    .into_response()
            }
        }
    };
}

/// Full config export for a registered project (merged with any active
/// overrides)
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/projects/{slug}/config",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:required:Project slug",
    tags = "config"
)]
async fn project_config(
    State(state): State<AppState>,
    Path(slug): Path<String>,
) -> impl IntoResponse {
    state.touch_activity();
    let ctx = require_project!(state, slug);
    let surface = require_config_surface!(ctx, slug);

    let entry = surface.entry_point_path(&ctx.root);
    match ctx.cache.export(&entry, ctx.import_path.as_deref()).await {
        Ok((json, _)) => Json(json).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}

/// Config surface schema: sections with descriptions, rationales, contracts,
/// and declared consumers
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/projects/{slug}/config/schema",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:required:Project slug",
    tags = "config"
)]
async fn project_config_schema(
    State(state): State<AppState>,
    Path(slug): Path<String>,
) -> impl IntoResponse {
    state.touch_activity();
    let ctx = require_project!(state, slug);
    let surface = require_config_surface!(ctx, slug);

    let sections: Vec<serde_json::Value> = surface
        .sections
        .iter()
        .map(|s| {
            serde_json::json!({
                "id": s.id,
                "file": s.file,
                "contract": s.contract,
                "description": s.description,
                "rationale": s.rationale,
                "mutable": s.mutable,
                "consumers": s.consumers.iter().map(|c| serde_json::json!({
                    "id": c.id,
                    "kind": format!("{:?}", c.kind),
                    "ref": c.reference,
                    "fields": c.fields,
                })).collect::<Vec<_>>(),
            })
        })
        .collect();

    Json(serde_json::json!({
        "slug": slug,
        "config_root": surface.config_root.display().to_string(),
        "entry_point": surface.entry_point,
        "kind": format!("{:?}", surface.kind),
        "contracts_path": surface.contracts_path,
        "sections": sections,
    }))
    .into_response()
}

/// Values for a single config section (from the merged NCL export)
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/projects/{slug}/config/{section}",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:required:Project slug; section:string:required:Section id",
    tags = "config"
)]
async fn project_config_section(
    State(state): State<AppState>,
    Path((slug, section)): Path<(String, String)>,
) -> impl IntoResponse {
    state.touch_activity();
    let ctx = require_project!(state, slug);
    let surface = require_config_surface!(ctx, slug);

    if surface.section(&section).is_none() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("section '{section}' not declared in config_surface")})),
        )
            .into_response();
    }

    let entry = surface.entry_point_path(&ctx.root);
    match ctx.cache.export(&entry, ctx.import_path.as_deref()).await {
        Ok((json, _)) => {
            let val = json
                .get(&section)
                .cloned()
                .unwrap_or(serde_json::Value::Null);
            Json(serde_json::json!({"slug": slug, "section": section, "values": val}))
                .into_response()
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}

/// Multi-consumer coherence report: unclaimed NCL fields, consumer field
/// mismatches
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/projects/{slug}/config/coherence",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:required:Project slug; section:string:optional:Filter to one section",
    tags = "config"
)]
async fn project_config_coherence(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    Query(q): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
    state.touch_activity();
    let ctx = require_project!(state, slug);
    let surface = require_config_surface!(ctx, slug);

    let section_filter = q.get("section").map(String::as_str);

    let report = crate::config_coherence::check_project(
        &slug,
        &surface,
        &ctx.root,
        &ctx.cache,
        ctx.import_path.as_deref(),
        section_filter,
    )
    .await;

    Json(serde_json::to_value(&report).unwrap_or(serde_json::Value::Null)).into_response()
}

/// Generated config documentation with rationales, override history, and
/// coherence status
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/projects/{slug}/config/quickref",
    auth = "none",
    actors = "agent, developer",
    params = "slug:string:required:Project slug; section:string:optional:Filter to one section; \
              format:string:optional:Output format (json|markdown)",
    tags = "config"
)]
async fn project_config_quickref(
    State(state): State<AppState>,
    Path(slug): Path<String>,
    Query(q): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
    state.touch_activity();
    let ctx = require_project!(state, slug);
    let surface = require_config_surface!(ctx, slug);

    let section_filter = q.get("section").map(String::as_str);

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

    Json(quickref).into_response()
}

fn index_section_fields(
    sec_val: &serde_json::Map<String, serde_json::Value>,
    section_id: &str,
    slug: &str,
    index: &mut std::collections::BTreeMap<(String, String), Vec<(String, serde_json::Value)>>,
) {
    for (field, value) in sec_val {
        if field.starts_with("_meta_") || field == "_overrides_meta" {
            continue;
        }
        index
            .entry((section_id.to_owned(), field.clone()))
            .or_default()
            .push((slug.to_owned(), value.clone()));
    }
}

/// Compare config surfaces across all registered projects: shared values,
/// conflicts, coverage gaps
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/config/cross-project",
    auth = "none",
    actors = "agent, developer",
    tags = "config"
)]
async fn config_cross_project(State(state): State<AppState>) -> impl IntoResponse {
    state.touch_activity();

    // Collect all registered projects that have a config_surface.
    let candidates: Vec<std::sync::Arc<crate::registry::ProjectContext>> = state
        .registry
        .all()
        .into_iter()
        .filter(|ctx| ctx.config_surface.is_some())
        .collect();

    // Export each project's full config. NclCache makes repeated calls cheap.
    // (section_id, field_path) → Vec<(slug, serde_json::Value)>
    let mut field_index: std::collections::BTreeMap<
        (String, String),
        Vec<(String, serde_json::Value)>,
    > = std::collections::BTreeMap::new();

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

    for ctx in &candidates {
        let surface = ctx.config_surface.as_ref().unwrap();
        let entry = surface.entry_point_path(&ctx.root);
        let export = ctx
            .cache
            .export(&entry, ctx.import_path.as_deref())
            .await
            .ok()
            .map(|(j, _)| j);

        let section_ids: Vec<String> = surface.sections.iter().map(|s| s.id.clone()).collect();

        if let Some(ref full) = export {
            for section in &surface.sections {
                let Some(sec_val) = full.get(&section.id).and_then(|v| v.as_object()) else {
                    continue;
                };
                index_section_fields(sec_val, &section.id, &ctx.slug, &mut field_index);
            }
        }

        project_summaries.push(serde_json::json!({
            "slug":        ctx.slug,
            "config_root": surface.config_root.display().to_string(),
            "kind":        format!("{:?}", surface.kind),
            "sections":    section_ids,
            "export_ok":   export.is_some(),
        }));
    }

    // Shared values: same (section, field) present in ≥2 projects with identical
    // value.
    let mut shared: Vec<serde_json::Value> = Vec::new();
    // Conflicts: same (section, field) with differing values across projects.
    let mut conflicts: Vec<serde_json::Value> = Vec::new();
    // Port collisions: numeric fields named `port` or ending in `_port`.
    let mut port_collisions: Vec<serde_json::Value> = Vec::new();

    for ((section_id, field), entries) in &field_index {
        if entries.len() < 2 {
            continue;
        }
        let first_val = &entries[0].1;
        let all_same = entries.iter().all(|(_, v)| v == first_val);

        let is_port_field = field == "port" || field.ends_with("_port");
        if is_port_field {
            if !all_same {
                // Different ports — not necessarily a conflict, but flag them.
                port_collisions.push(serde_json::json!({
                    "section": section_id,
                    "field":   field,
                    "values":  entries.iter().map(|(slug, v)| serde_json::json!({ "slug": slug, "value": v })).collect::<Vec<_>>(),
                }));
            }
        } else if all_same {
            shared.push(serde_json::json!({
                "section": section_id,
                "field":   field,
                "value":   first_val,
                "projects": entries.iter().map(|(s, _)| s).collect::<Vec<_>>(),
            }));
        } else {
            conflicts.push(serde_json::json!({
                "section": section_id,
                "field":   field,
                "values":  entries.iter().map(|(slug, v)| serde_json::json!({ "slug": slug, "value": v })).collect::<Vec<_>>(),
            }));
        }
    }

    // Coverage gaps: section present in some projects but not others.
    let all_sections: std::collections::BTreeSet<String> =
        field_index.keys().map(|(s, _)| s.clone()).collect();
    let mut coverage_gaps: Vec<serde_json::Value> = Vec::new();
    for section_id in &all_sections {
        let present_in: Vec<String> = candidates
            .iter()
            .filter(|ctx| {
                ctx.config_surface
                    .as_ref()
                    .map(|s| s.sections.iter().any(|sec| &sec.id == section_id))
                    .unwrap_or(false)
            })
            .map(|ctx| ctx.slug.clone())
            .collect();
        if present_in.len() < candidates.len() {
            let absent_in: Vec<String> = candidates
                .iter()
                .filter(|ctx| !present_in.contains(&ctx.slug))
                .map(|ctx| ctx.slug.clone())
                .collect();
            coverage_gaps.push(serde_json::json!({
                "section":    section_id,
                "present_in": present_in,
                "absent_in":  absent_in,
            }));
        }
    }

    Json(serde_json::json!({
        "projects":       project_summaries,
        "shared_values":  shared,
        "conflicts":      conflicts,
        "port_report":    port_collisions,
        "coverage_gaps":  coverage_gaps,
        "total_projects": candidates.len(),
    }))
    .into_response()
}

#[derive(Deserialize)]
pub struct ConfigUpdateRequest {
    /// JSON object with fields to set in this section.
    pub values: serde_json::Value,
    /// Reason for the change — written as a comment in the override file.
    #[serde(default)]
    pub reason: String,
    /// When true (default for safety): return the proposed override NCL
    /// without writing to disk.
    #[serde(default = "default_dry_run")]
    pub dry_run: bool,
}

fn default_dry_run() -> bool {
    true
}

/// Mutate a config section via the override layer. dry_run=true (default)
/// returns the proposed change without writing.
#[ontoref_derive::onto_api(
    method = "PUT",
    path = "/projects/{slug}/config/{section}",
    auth = "admin",
    actors = "agent, developer",
    params = "slug:string:required:Project slug; section:string:required:Section id",
    tags = "config"
)]
async fn project_config_update(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    Path((slug, section)): Path<(String, String)>,
    Json(req): Json<ConfigUpdateRequest>,
) -> impl IntoResponse {
    state.touch_activity();

    let ctx = require_project!(state, slug);

    // Require admin auth if the project has keys.
    if ctx.auth_enabled() {
        let bearer = headers
            .get(axum::http::header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .map(str::trim)
            .filter(|s| !s.is_empty());

        match bearer {
            None => {
                return (
                    StatusCode::UNAUTHORIZED,
                    Json(serde_json::json!({"error": "Authorization: Bearer <password> required"})),
                )
                    .into_response();
            }
            Some(password) => match ctx.verify_key(password).map(|m| m.role) {
                Some(crate::registry::Role::Admin) => {}
                Some(crate::registry::Role::Viewer) => {
                    return (
                        StatusCode::FORBIDDEN,
                        Json(serde_json::json!({"error": "admin role required to mutate config"})),
                    )
                        .into_response();
                }
                None => {
                    return (
                        StatusCode::UNAUTHORIZED,
                        Json(serde_json::json!({"error": "invalid credentials"})),
                    )
                        .into_response();
                }
            },
        }
    }

    let surface = require_config_surface!(ctx, slug);

    // TypeDialog projects manage their own write pipeline (form.toml →
    // validators → fragments). The NCL override layer is not applicable.
    if matches!(surface.kind, crate::registry::ConfigKind::TypeDialog) {
        return (
            StatusCode::METHOD_NOT_ALLOWED,
            Json(serde_json::json!({
                "error": "TypeDialog config surfaces are not mutable via this endpoint",
                "detail": "TypeDialog projects use form.toml + validators + fragments. \
                           Mutate values through the TypeDialog pipeline directly.",
                "kind": "TypeDialog",
            })),
        )
            .into_response();
    }

    let section_meta = match surface.section(&section) {
        Some(s) => s.clone(),
        None => return (
            StatusCode::NOT_FOUND,
            Json(
                serde_json::json!({"error": format!("section '{section}' not in config_surface")}),
            ),
        )
            .into_response(),
    };

    if !section_meta.mutable {
        return (
            StatusCode::METHOD_NOT_ALLOWED,
            Json(serde_json::json!({
                "error": format!("section '{section}' is marked immutable in manifest.ncl"),
                "detail": "Set mutable = true in the config_section declaration to enable writes.",
            })),
        )
            .into_response();
    }

    if ctx.push_only {
        // push_only projects never have writable local files.
        return generate_override_diff(&surface, &section, &req, &ctx.root).into_response();
    }

    if req.dry_run {
        return generate_override_diff(&surface, &section, &req, &ctx.root).into_response();
    }

    // Apply the override: write {section}.overrides.ncl, patch entry point,
    // validate with nickel export, revert on failure.
    match apply_config_override(
        &surface,
        &section,
        &req,
        &ctx.root,
        &ctx.cache,
        ctx.import_path.as_deref(),
    )
    .await
    {
        Ok(result) => Json(result).into_response(),
        Err(e) => (
            StatusCode::UNPROCESSABLE_ENTITY,
            Json(serde_json::json!({"error": e.to_string()})),
        )
            .into_response(),
    }
}

/// Generate a dry-run diff as a `serde_json::Value` (shared by HTTP and MCP).
pub fn generate_override_diff_value(
    surface: &crate::registry::ConfigSurface,
    section: &str,
    req: &ConfigUpdateRequest,
    project_root: &std::path::Path,
) -> serde_json::Value {
    let overrides_dir = surface.resolved_overrides_dir();
    let override_path = project_root
        .join(overrides_dir)
        .join(format!("{section}.overrides.ncl"));
    let ncl_content = render_override_ncl(section, &req.values, &req.reason, project_root);
    serde_json::json!({
        "dry_run": true,
        "section": section,
        "override_file": override_path.display().to_string(),
        "proposed_ncl": ncl_content,
        "values": req.values,
    })
}

/// Generate a dry-run HTTP response showing what the override file would look
/// like.
fn generate_override_diff(
    surface: &crate::registry::ConfigSurface,
    section: &str,
    req: &ConfigUpdateRequest,
    project_root: &std::path::Path,
) -> impl IntoResponse {
    Json(generate_override_diff_value(
        surface,
        section,
        req,
        project_root,
    ))
}

/// Render the NCL content for an override file.
fn render_override_ncl(
    section: &str,
    values: &serde_json::Value,
    reason: &str,
    _project_root: &std::path::Path,
) -> String {
    let ts = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);

    let mut lines = vec![
        format!("# {section}.overrides.ncl — generated by ontoref"),
        "# DO NOT edit manually — managed by ontoref config surface.".to_owned(),
        "# To revert: delete this file and remove the import from the entry point.".to_owned(),
        "{".to_owned(),
        format!("  _overrides_meta = {{"),
        format!("    managed_by = \"ontoref\","),
        format!("    updated_at = {ts},"),
    ];

    if !reason.is_empty() {
        lines.push(format!("    reason = {reason:?},"));
    }
    lines.push("  },".to_owned());

    // Emit each key as a top-level field override.
    if let Some(obj) = values.as_object() {
        for (key, val) in obj {
            let ncl_val = json_to_ncl_literal(val);
            lines.push(format!("  {section}.{key} = {ncl_val},"));
        }
    }

    lines.push("}".to_owned());
    lines.join("\n")
}

/// Best-effort conversion of a JSON value to a NCL literal.
/// Supports primitives, arrays of primitives, and nested objects.
fn json_to_ncl_literal(val: &serde_json::Value) -> String {
    match val {
        serde_json::Value::Null => "null".to_owned(),
        serde_json::Value::Bool(b) => b.to_string(),
        serde_json::Value::Number(n) => n.to_string(),
        serde_json::Value::String(s) => format!("{s:?}"),
        serde_json::Value::Array(arr) => {
            let items: Vec<String> = arr.iter().map(json_to_ncl_literal).collect();
            format!("[{}]", items.join(", "))
        }
        serde_json::Value::Object(obj) => {
            let fields: Vec<String> = obj
                .iter()
                .map(|(k, v)| format!("  {k} = {}", json_to_ncl_literal(v)))
                .collect();
            format!("{{\n{}\n}}", fields.join(",\n"))
        }
    }
}

/// Write the override file, patch the entry point, and validate.
/// Reverts on validation failure.
pub async fn apply_config_override(
    surface: &crate::registry::ConfigSurface,
    section: &str,
    req: &ConfigUpdateRequest,
    project_root: &std::path::Path,
    cache: &crate::cache::NclCache,
    import_path: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
    use std::io::Write;

    let overrides_dir = project_root.join(surface.resolved_overrides_dir());
    let override_file = overrides_dir.join(format!("{section}.overrides.ncl"));
    let entry_point = surface.entry_point_path(project_root);

    let ncl_content = render_override_ncl(section, &req.values, &req.reason, project_root);

    // Backup old override file if it exists.
    let backup = override_file.with_extension("ncl.bak");
    if override_file.exists() {
        std::fs::copy(&override_file, &backup)?;
    }

    // Write the override file.
    {
        let mut f = std::fs::File::create(&override_file)?;
        f.write_all(ncl_content.as_bytes())?;
    }

    // Patch entry point to import the override if not already present.
    let override_import = format!("(import \"./{section}.overrides.ncl\")");
    let entry_original = std::fs::read_to_string(&entry_point)?;
    let patched = if !entry_original.contains(&override_import) {
        // Append import at the end — NCL merge chain, override wins.
        Some(format!("{entry_original}\n& {override_import}\n"))
    } else {
        None
    };

    if let Some(ref new_content) = patched {
        std::fs::write(&entry_point, new_content)?;
    }

    // Validate by re-exporting the entry point.
    cache.invalidate_file(&override_file);
    cache.invalidate_file(&entry_point);

    match cache.export(&entry_point, import_path).await {
        Ok(_) => {
            // Clean up backup on success.
            let _ = std::fs::remove_file(&backup);
            Ok(serde_json::json!({
                "applied": true,
                "section": section,
                "override_file": override_file.display().to_string(),
                "values": req.values,
            }))
        }
        Err(e) => {
            // Revert: restore backup or remove the override file.
            if backup.exists() {
                let _ = std::fs::copy(&backup, &override_file);
                let _ = std::fs::remove_file(&backup);
            } else {
                let _ = std::fs::remove_file(&override_file);
            }
            // Restore entry point if we patched it.
            if patched.is_some() {
                let _ = std::fs::write(&entry_point, &entry_original);
            }
            cache.invalidate_file(&override_file);
            cache.invalidate_file(&entry_point);
            anyhow::bail!("nickel export validation failed after override: {e}")
        }
    }
}

/// Exchange a key for a session token.
///
/// Accepts project keys (looked up by slug) or the daemon admin password.
/// Returns a short-lived token suitable for use as `Authorization: Bearer
/// <token>` or as a browser session cookie (set by the UI login flow).
///
/// Rate-limited: repeated failures from the same IP exhaust the same budget as
/// failed Bearer attempts on all other endpoints.
#[cfg(feature = "ui")]
#[derive(Deserialize)]
struct SessionRequest {
    /// Raw (unhashed) key to verify.
    key: String,
    /// Project slug to authenticate against. `"_daemon"` or absent → daemon
    /// admin.
    project: Option<String>,
}

#[cfg(feature = "ui")]
#[derive(Serialize)]
struct SessionResponse {
    token: String,
    role: String,
    key_label: String,
    project: String,
    expires_at: u64,
}

#[cfg(feature = "ui")]
async fn session_create(
    State(state): State<AppState>,
    axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
    Json(req): Json<SessionRequest>,
) -> impl IntoResponse {
    use std::time::{SystemTime, UNIX_EPOCH};

    let client_ip = addr.ip();

    if state.auth_rate_limiter.is_limited(client_ip) {
        return (
            StatusCode::TOO_MANY_REQUESTS,
            Json(serde_json::json!({
                "error": "too many failed auth attempts",
                "retry_after_secs": 60
            })),
        )
            .into_response();
    }

    let target = req.project.as_deref().unwrap_or("_daemon");

    // Daemon admin path.
    if target == "_daemon" {
        let Some(ref hash) = state.daemon_admin_hash else {
            state.auth_rate_limiter.record_failure(client_ip);
            return (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({"error": "daemon admin auth not configured"})),
            )
                .into_response();
        };
        let key_entry = crate::registry::KeyEntry {
            role: crate::registry::Role::Admin,
            hash: hash.clone(),
            label: "daemon-admin".to_string(),
        };
        if crate::registry::verify_keys_list(std::slice::from_ref(&key_entry), &req.key).is_some() {
            state.auth_rate_limiter.record_success(client_ip);
            let token = state.sessions.create(
                "_daemon".to_string(),
                crate::registry::Role::Admin,
                "daemon-admin".to_string(),
            );
            let expires_at = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs()
                + 30 * 24 * 3600;
            return Json(SessionResponse {
                token,
                role: "admin".to_string(),
                key_label: "daemon-admin".to_string(),
                project: "_daemon".to_string(),
                expires_at,
            })
            .into_response();
        }
        state.auth_rate_limiter.record_failure(client_ip);
        return (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"error": "invalid daemon admin credentials"})),
        )
            .into_response();
    }

    // Project key path.
    let Some(ctx) = state.registry.get(target) else {
        state.auth_rate_limiter.record_failure(client_ip);
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("project '{target}' not registered")})),
        )
            .into_response();
    };

    match ctx.verify_key(&req.key) {
        Some(km) => {
            state.auth_rate_limiter.record_success(client_ip);
            let role_str = match km.role {
                crate::registry::Role::Admin => "admin",
                crate::registry::Role::Viewer => "viewer",
            };
            let token = state
                .sessions
                .create(target.to_string(), km.role, km.label.clone());
            let expires_at = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs()
                + 30 * 24 * 3600;
            Json(SessionResponse {
                token,
                role: role_str.to_string(),
                key_label: km.label,
                project: target.to_string(),
                expires_at,
            })
            .into_response()
        }
        None => {
            state.auth_rate_limiter.record_failure(client_ip);
            (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({"error": "invalid credentials"})),
            )
                .into_response()
        }
    }
}

/// `GET /sessions?project=<slug>` — list active sessions for a project.
///
/// Auth:
/// - Any valid session for the requested project (viewer or admin).
/// - Daemon admin session (slug == `_daemon`) can query any project.
/// - Without `?project=`, requires daemon admin and returns all sessions.
#[cfg(feature = "ui")]
#[derive(Deserialize)]
struct SessionsQuery {
    project: Option<String>,
}

#[cfg(feature = "ui")]
async fn sessions_list(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    Query(q): Query<SessionsQuery>,
) -> axum::response::Response {
    let acting = match require_session(&state, &headers) {
        Ok(e) => e,
        Err(r) => return *r,
    };

    let views = match q.project.as_deref() {
        Some(slug) => {
            // Must be a session for that project or daemon admin.
            let authorized = acting.slug == "_daemon" || acting.slug == slug;
            if !authorized {
                return (
                    StatusCode::FORBIDDEN,
                    Json(serde_json::json!({"error": "session is not scoped to this project"})),
                )
                    .into_response();
            }
            if state.registry.get(slug).is_none() {
                return (
                    StatusCode::NOT_FOUND,
                    Json(serde_json::json!({"error": format!("project '{slug}' not registered")})),
                )
                    .into_response();
            }
            state.sessions.list_for_slug(slug)
        }
        None => {
            // No project filter — daemon admin only.
            if acting.slug != "_daemon" {
                return (
                    StatusCode::FORBIDDEN,
                    Json(serde_json::json!({"error": "daemon admin session required to list all sessions"})),
                )
                    .into_response();
            }
            state.sessions.list_all()
        }
    };

    Json(serde_json::json!({"sessions": views})).into_response()
}

/// `DELETE /sessions/{id}` — revoke a session by its public id.
///
/// Auth:
/// - Project admin: may revoke sessions scoped to their own project.
/// - Daemon admin: may revoke any session.
#[cfg(feature = "ui")]
async fn session_revoke(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    Path(id): Path<String>,
) -> axum::response::Response {
    let acting = match require_session(&state, &headers) {
        Ok(e) => e,
        Err(r) => return *r,
    };

    use crate::session::RevokeResult;
    match state.sessions.revoke_by_id(&id, &acting.slug, &acting.role) {
        RevokeResult::Revoked => StatusCode::NO_CONTENT.into_response(),
        RevokeResult::NotFound => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "session not found or already expired"})),
        )
            .into_response(),
        RevokeResult::Forbidden => (
            StatusCode::FORBIDDEN,
            Json(serde_json::json!({"error": "not authorized to revoke this session"})),
        )
            .into_response(),
    }
}

// ── SSO surface: JWKS + introspect ────────────────────────────────────

/// Publish the daemon's current JWKS for SSO-mode panels.
///
/// Includes both the active key and the previous one (when a rotation is in
/// flight), so tokens minted under the rotated-out key remain verifiable until
/// `TokenIssuer::forget_previous()` is called.
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/.well-known/ontoref-keys.json",
    auth = "none",
    actors = "agent, developer, ci, admin",
    tags = "meta, sso"
)]
async fn jwks_handler(State(state): State<AppState>) -> Json<serde_json::Value> {
    Json(state.token_issuer.jwks().to_json())
}

#[derive(Deserialize)]
struct IntrospectRequest {
    token: String,
    /// Optional audience filter. When omitted the introspection accepts any
    /// audience (`Audience::Any`).
    #[serde(default)]
    audience: Option<String>,
}

/// RFC 7662-shaped introspection of an ontoref-issued JWT.
///
/// Returns `{active: false}` for any unverifiable credential — no leakage of
/// failure mode. Successful verification returns the introspection metadata
/// extracted from the token (subject, role, scope, expiry).
#[ontoref_derive::onto_api(
    method = "POST",
    path = "/.session/introspect",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "token:string:required:JWT to introspect; audience:string:optional:Expected aud \
              (Exact match)",
    tags = "meta, sso"
)]
async fn introspect_handler(
    State(state): State<AppState>,
    Json(body): Json<IntrospectRequest>,
) -> Json<serde_json::Value> {
    use auth::{identity::IdentityProvider, Audience};
    let audience = match body.audience {
        Some(a) if !a.is_empty() => Audience::Exact(a),
        _ => Audience::Any,
    };
    match state.identity_provider.verify(&body.token, &audience).await {
        Ok(identity) => Json(serde_json::json!({
            "active": true,
            "sub": identity.subject,
            "role": identity.role.to_string(),
            "key_label": identity.key_label,
            "issuer": identity.introspection.issuer,
            "audience": identity.introspection.audience,
            "scope": identity.introspection.scope,
            "issued_at": identity.introspection.issued_at,
            "expires_at": identity.introspection.expires_at,
            "kid": identity.introspection.kid.as_ref().map(|k| k.as_str()),
        })),
        Err(_) => Json(serde_json::json!({ "active": false })),
    }
}

/// Write a snapshot of all project keys to `{config_dir}/keys-overlay.json`.
/// Format: `{ "slug": [KeyEntry, ...], ... }` — loaded at next boot by main.rs.
fn persist_keys_overlay(config_dir: &std::path::Path, registry: &crate::registry::ProjectRegistry) {
    let map: std::collections::HashMap<String, Vec<crate::registry::KeyEntry>> = registry
        .all()
        .into_iter()
        .filter_map(|ctx| {
            let keys = ctx.keys.read().unwrap_or_else(|e| e.into_inner()).clone();
            if keys.is_empty() {
                None
            } else {
                Some((ctx.slug.clone(), keys))
            }
        })
        .collect();

    let path = config_dir.join("keys-overlay.json");
    match serde_json::to_string_pretty(&map) {
        Ok(json) => {
            if let Err(e) = std::fs::write(&path, json) {
                tracing::warn!(error = %e, path = %path.display(), "failed to persist keys overlay");
            }
        }
        Err(e) => tracing::warn!(error = %e, "failed to serialize keys overlay"),
    }
}

// ── Domain graph endpoints
// ────────────────────────────────────────────────────

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

/// Domain implementations for a project: domains it is consuming
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/domain/list",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "domain, describe"
)]
async fn domain_list(
    State(state): State<AppState>,
    Query(q): Query<DomainQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let ctx = q
        .slug
        .as_deref()
        .and_then(|s| state.registry.get(s))
        .unwrap_or_else(|| state.registry.primary());
    let domains = crate::domain::project_domains(&ctx.root);
    (
        StatusCode::OK,
        Json(serde_json::json!({ "slug": ctx.slug, "domains": domains })),
    )
}

/// All registered projects implementing a given domain id
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/domain/consumers",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "id:string:required:Domain id (e.g. rustelo, provisioning)",
    tags = "domain, describe"
)]
async fn domain_consumers(
    State(state): State<AppState>,
    Query(q): Query<DomainQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let id = match q.id {
        Some(ref s) if !s.is_empty() => s.as_str(),
        _ => {
            return (
                StatusCode::BAD_REQUEST,
                Json(serde_json::json!({ "error": "id required" })),
            )
        }
    };
    let consumers = crate::domain::consumers_of(&state.registry, id);
    (
        StatusCode::OK,
        Json(serde_json::json!({ "domain_id": id, "consumers": consumers })),
    )
}

/// Domain metadata from the ontoref domain registry
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/domain/show",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "id:string:required:Domain id",
    tags = "domain, describe"
)]
async fn domain_show(
    State(state): State<AppState>,
    Query(q): Query<DomainQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let id = match q.id {
        Some(ref s) if !s.is_empty() => s.as_str(),
        _ => {
            return (
                StatusCode::BAD_REQUEST,
                Json(serde_json::json!({ "error": "id required" })),
            )
        }
    };
    let root = state.registry.primary().root.clone();
    // Walk up to find ontoref root (domains/ dir).
    let ontoref_root = std::env::var("ONTOREF_ROOT")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|_| root.clone());
    let meta = crate::domain::domain_meta(&ontoref_root, id);
    let consumers = crate::domain::consumers_of(&state.registry, id);
    (
        StatusCode::OK,
        Json(serde_json::json!({
            "meta":      meta,
            "consumers": consumers,
        })),
    )
}

// ── Tier + config describe endpoints ─────────────────────────────────────────

/// Combined config.ncl + card.ncl export — runtime config and project beacon in
/// one call
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/describe/config",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "describe, config"
)]
async fn describe_config(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());

    let config_path = root.join(".ontoref").join("config.ncl");
    let card_path = root.join(".ontoref").join("card.ncl");

    let config = if config_path.exists() {
        match cache.export(&config_path, import_path.as_deref()).await {
            Ok((data, _)) => data,
            Err(e) => {
                error!(path = %config_path.display(), error = %e, "describe_config: config export failed");
                serde_json::json!({ "error": e.to_string() })
            }
        }
    } else {
        serde_json::Value::Null
    };

    let card = if card_path.exists() {
        match cache.export(&card_path, import_path.as_deref()).await {
            Ok((data, _)) => data,
            Err(e) => {
                warn!(path = %card_path.display(), error = %e, "describe_config: card export failed");
                serde_json::Value::Null
            }
        }
    } else {
        serde_json::Value::Null
    };

    (
        StatusCode::OK,
        Json(serde_json::json!({ "config": config, "card": card })),
    )
}

/// Current tier declaration with available/locked capability matrix
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/tier/status",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "tier, describe"
)]
async fn tier_status(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let slug = q
        .slug
        .as_deref()
        .unwrap_or_else(|| state.registry.primary_slug());
    let ctx = match state.registry.get(slug) {
        Some(c) => c,
        None => state.registry.primary(),
    };
    let ops = ctx.ops();
    let view = ontoref_ops::capabilities::capability_view(ops.tier);

    (
        StatusCode::OK,
        Json(serde_json::json!({
            "slug":      ctx.slug,
            "tier":      ops.tier,
            "phase":     ops.phase,
            "available": view.available,
            "locked":    view.locked,
        })),
    )
}

/// Forward projection: what the next tier adds and why
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/tier/next",
    auth = "none",
    actors = "agent, developer, ci, admin",
    params = "slug:string:optional:Project slug (defaults to primary)",
    tags = "tier, describe"
)]
async fn tier_next(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let slug = q
        .slug
        .as_deref()
        .unwrap_or_else(|| state.registry.primary_slug());
    let ctx = match state.registry.get(slug) {
        Some(c) => c,
        None => state.registry.primary(),
    };
    let ops = ctx.ops();
    let projection = ontoref_ops::capabilities::tier_projection(ops.tier);

    (
        StatusCode::OK,
        Json(
            serde_json::to_value(projection)
                .unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() })),
        ),
    )
}

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

/// Resolve a path that may be absolute or relative to project_root.
/// Absolute paths are accepted as-is (daemon is loopback-only; OS enforces
/// access). Relative paths are resolved against project_root and must stay
/// within it.
fn resolve_any_path(
    project_root: &std::path::Path,
    path: &str,
) -> std::result::Result<PathBuf, (StatusCode, String)> {
    let p = PathBuf::from(path);
    if p.is_absolute() {
        return p.canonicalize().map_err(|e| {
            (
                StatusCode::BAD_REQUEST,
                format!("path does not exist or is inaccessible: {e}"),
            )
        });
    }
    let joined = project_root.join(p);
    let canonical = joined.canonicalize().map_err(|e| {
        (
            StatusCode::BAD_REQUEST,
            format!("path does not exist or is inaccessible: {e}"),
        )
    })?;
    if !canonical.starts_with(project_root) {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("path escapes project root: {path}"),
        ));
    }
    Ok(canonical)
}

/// Resolve a path relative to the project root and verify it stays within.
/// Rejects absolute paths — used for cache management operations scoped to a
/// project.
fn resolve_path(
    project_root: &std::path::Path,
    path: &str,
) -> std::result::Result<PathBuf, (StatusCode, String)> {
    let p = PathBuf::from(path);
    if p.is_absolute() {
        return Err((
            StatusCode::BAD_REQUEST,
            "absolute paths are not accepted".to_string(),
        ));
    }
    let joined = project_root.join(p);
    let canonical = joined.canonicalize().map_err(|e| {
        (
            StatusCode::BAD_REQUEST,
            format!("path does not exist or is inaccessible: {e}"),
        )
    })?;
    if !canonical.starts_with(project_root) {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("path escapes project root: {path}"),
        ));
    }
    Ok(canonical)
}

impl IntoResponse for crate::error::DaemonError {
    fn into_response(self) -> axum::response::Response {
        let body = serde_json::json!({"error": self.to_string()});
        (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
    }
}

// ── Service toggles ──────────────────────────────────────────────────────────

#[derive(Serialize)]
struct ServiceEntry {
    service: &'static str,
    compiled: bool,
    enabled: bool,
}

/// List all optional services with their compiled-in and runtime-enabled
/// status.
async fn services_status(State(state): State<AppState>) -> impl IntoResponse {
    state.touch_activity();
    let services = vec![
        ServiceEntry {
            service: "mcp",
            #[cfg(feature = "mcp")]
            compiled: true,
            #[cfg(not(feature = "mcp"))]
            compiled: false,
            #[cfg(feature = "mcp")]
            enabled: state.service_flags.mcp.load(Ordering::Relaxed),
            #[cfg(not(feature = "mcp"))]
            enabled: false,
        },
        ServiceEntry {
            service: "graphql",
            #[cfg(feature = "graphql")]
            compiled: true,
            #[cfg(not(feature = "graphql"))]
            compiled: false,
            #[cfg(feature = "graphql")]
            enabled: state.service_flags.graphql.load(Ordering::Relaxed),
            #[cfg(not(feature = "graphql"))]
            enabled: false,
        },
    ];
    Json(services)
}

#[derive(Deserialize)]
struct ServiceToggleRequest {
    // used in the #[cfg(feature = "ui")] handler; appears dead without that feature
    #[cfg_attr(not(feature = "ui"), allow(dead_code))]
    enabled: bool,
}

/// Enable or disable a named optional service at runtime. Daemon admin Bearer
/// required.
///
/// Gated on `ui` because daemon admin auth (`daemon_admin_hash`) requires the
/// session store compiled in with the `ui` feature. Without `ui`, the endpoint
/// returns 501 — no auth surface exists to protect the toggle.
#[cfg(feature = "ui")]
async fn service_toggle(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    Path(service): Path<String>,
    Json(req): Json<ServiceToggleRequest>,
) -> impl IntoResponse {
    state.touch_activity();

    let Some(ref admin_hash) = state.daemon_admin_hash else {
        return (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"error": "daemon admin auth not configured"})),
        )
            .into_response();
    };
    let bearer = headers
        .get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .map(str::trim)
        .filter(|s| !s.is_empty());
    let Some(token) = bearer else {
        return (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"error": "Authorization: Bearer <admin-token> required"})),
        )
            .into_response();
    };
    let key_entry = crate::registry::KeyEntry {
        role: crate::registry::Role::Admin,
        hash: admin_hash.clone(),
        label: "daemon-admin".to_string(),
    };
    if crate::registry::verify_keys_list(std::slice::from_ref(&key_entry), token).is_none() {
        return (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"error": "invalid admin credentials"})),
        )
            .into_response();
    }

    match state.service_flags.set(&service, req.enabled) {
        Some(new_state) => {
            tracing::info!(service = %service, enabled = new_state, "service toggled");
            Json(serde_json::json!({"service": service, "enabled": new_state})).into_response()
        }
        None => (
            StatusCode::NOT_FOUND,
            Json(
                serde_json::json!({"error": format!("unknown or not compiled service: {service}")}),
            ),
        )
            .into_response(),
    }
}

#[cfg(not(feature = "ui"))]
async fn service_toggle(
    State(_state): State<AppState>,
    Path(_service): Path<String>,
    Json(_req): Json<ServiceToggleRequest>,
) -> impl IntoResponse {
    (
        StatusCode::NOT_IMPLEMENTED,
        Json(
            serde_json::json!({"error": "service toggle requires the ui feature (daemon admin auth)"}),
        ),
    )
}

#[cfg(test)]
mod tests {
    use std::sync::atomic::AtomicU64;
    use std::sync::Arc;
    use std::time::Instant;

    use axum::body::to_bytes;
    use axum::http::{Request, StatusCode};
    use tower::ServiceExt as _;

    use super::*;
    use crate::registry::{make_context, ContextSpec, ProjectRegistry};

    /// Build a minimal `AppState` suitable for handler tests.
    ///
    /// Auth is disabled (no keys). Registry primary slug is "test".
    /// All optional feature-gated fields are `None`.
    fn test_state(project_root: std::path::PathBuf) -> AppState {
        let ctx = make_context(ContextSpec {
            slug: "test".into(),
            root: project_root.clone(),
            import_path: None,
            keys: vec![],
            remote_url: String::new(),
            push_only: false,
            stale_actor_timeout: 300,
            max_notifications: 100,
            ack_required: vec![],
            config_surface: None,
        });
        let ctx = Arc::new(ctx);
        let actors = Arc::clone(&ctx.actors);
        let notifications = Arc::clone(&ctx.notifications);
        let cache = Arc::clone(&ctx.cache);
        let registry = Arc::new(ProjectRegistry::with_primary(
            "test".into(),
            ctx,
            vec![],
            300,
            100,
        ));
        AppState {
            cache,
            project_root,
            ontoref_root: None,
            started_at: Instant::now(),
            last_activity: Arc::new(AtomicU64::new(0)),
            actors,
            notifications,
            nickel_import_path: None,
            #[cfg(feature = "db")]
            db: None,
            #[cfg(feature = "nats")]
            nats: None,
            #[cfg(feature = "ui")]
            tera: None,
            #[cfg(feature = "ui")]
            public_dir: None,
            registry,
            new_project_tx: None,
            config_dir: None,
            #[cfg(feature = "ui")]
            sessions: Arc::new(crate::session::SessionStore::new()),
            #[cfg(feature = "mcp")]
            mcp_current_project: Arc::new(std::sync::RwLock::new(None)),
            watcher_map: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
            auth_rate_limiter: Arc::new(AuthRateLimiter::new(false)),
            #[cfg(feature = "ui")]
            ncl_write_lock: Arc::new(crate::ui::ncl_write::NclWriteLock::new()),
            #[cfg(feature = "ui")]
            daemon_admin_hash: None,
            #[cfg(feature = "graphql")]
            graphql_read_token_hash: None,
            service_flags: ServiceFlags::new(),
            token_issuer: {
                let issuer = auth::TokenIssuer::generate("ontoref-daemon-test".into());
                Arc::new(issuer)
            },
            identity_provider: {
                let issuer = auth::TokenIssuer::generate("ontoref-daemon-test".into());
                let jwks = issuer.jwks();
                Arc::new(auth::SignedTokenProvider::new(
                    "ontoref-daemon-test".into(),
                    jwks,
                ))
            },
        }
    }

    async fn body_json(resp: axum::response::Response) -> serde_json::Value {
        let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
        serde_json::from_slice(&bytes).unwrap()
    }

    #[tokio::test]
    async fn health_returns_ok_status() {
        let state = test_state(std::path::PathBuf::from("/tmp/test-project"));
        let app = router(state);

        let resp = app
            .oneshot(
                Request::builder()
                    .uri("/health")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(resp.status(), StatusCode::OK);
        let json = body_json(resp).await;
        assert_eq!(json["status"], "ok");
        assert_eq!(json["project_root"], "/tmp/test-project");
        assert_eq!(json["active_actors"], 0);
        assert_eq!(json["cache_entries"], 0);
    }

    #[tokio::test]
    async fn cache_stats_empty_on_fresh_state() {
        let state = test_state(std::path::PathBuf::from("/tmp/test-project"));
        let app = router(state);

        let resp = app
            .oneshot(
                Request::builder()
                    .uri("/cache/stats")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(resp.status(), StatusCode::OK);
        let json = body_json(resp).await;
        assert_eq!(json["entries"], 0);
        assert_eq!(json["hits"], 0);
        assert_eq!(json["misses"], 0);
        assert_eq!(json["hit_rate"], 0.0);
    }

    #[tokio::test]
    async fn actor_register_returns_201_with_token() {
        let state = test_state(std::path::PathBuf::from("/tmp/test-project"));
        let app = router(state);

        let body = serde_json::json!({
            "actor_type": "developer",
            "project": "test",
            "hostname": "host-a",
            "pid": 1001
        });
        let resp = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/actors/register")
                    .header("content-type", "application/json")
                    .body(axum::body::Body::from(body.to_string()))
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(resp.status(), StatusCode::CREATED);
        let json = body_json(resp).await;
        assert!(json["token"].is_string(), "token must be present");
        assert_eq!(json["actors_connected"], 1);
    }

    #[tokio::test]
    async fn actor_deregister_204_then_404() {
        let state = test_state(std::path::PathBuf::from("/tmp/test-project"));

        let register_body = serde_json::json!({
            "actor_type": "agent",
            "project": "test",
            "hostname": "host-b",
            "pid": 1002
        });
        let register_resp = router(state.clone())
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/actors/register")
                    .header("content-type", "application/json")
                    .body(axum::body::Body::from(register_body.to_string()))
                    .unwrap(),
            )
            .await
            .unwrap();
        let token = body_json(register_resp).await["token"]
            .as_str()
            .unwrap()
            .to_string();

        let del_resp = router(state.clone())
            .oneshot(
                Request::builder()
                    .method("DELETE")
                    .uri(format!("/actors/{token}"))
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(del_resp.status(), StatusCode::NO_CONTENT);

        let del_again = router(state)
            .oneshot(
                Request::builder()
                    .method("DELETE")
                    .uri(format!("/actors/{token}"))
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(del_again.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn actor_touch_204_for_known_404_for_unknown() {
        let state = test_state(std::path::PathBuf::from("/tmp/test-project"));

        let reg_body = serde_json::json!({
            "actor_type": "ci",
            "project": "test",
            "hostname": "host-c",
            "pid": 1003
        });
        let reg_resp = router(state.clone())
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/actors/register")
                    .header("content-type", "application/json")
                    .body(axum::body::Body::from(reg_body.to_string()))
                    .unwrap(),
            )
            .await
            .unwrap();
        let token = body_json(reg_resp).await["token"]
            .as_str()
            .unwrap()
            .to_string();

        let touch_ok = router(state.clone())
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri(format!("/actors/{token}/touch"))
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(touch_ok.status(), StatusCode::NO_CONTENT);

        let touch_miss = router(state)
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/actors/nonexistent-token/touch")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(touch_miss.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn actors_list_reflects_registered_actors() {
        let state = test_state(std::path::PathBuf::from("/tmp/test-project"));

        for i in 0..3 {
            let body = serde_json::json!({
                "actor_type": "developer",
                "project": "test",
                "hostname": format!("host-{i}"),
                "pid": 2000u32 + i as u32
            });
            router(state.clone())
                .oneshot(
                    Request::builder()
                        .method("POST")
                        .uri("/actors/register")
                        .header("content-type", "application/json")
                        .body(axum::body::Body::from(body.to_string()))
                        .unwrap(),
                )
                .await
                .unwrap();
        }

        let list_resp = router(state)
            .oneshot(
                Request::builder()
                    .uri("/actors")
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(list_resp.status(), StatusCode::OK);
        let json = body_json(list_resp).await;
        assert_eq!(json["total"], 3);
        assert_eq!(json["actors"].as_array().unwrap().len(), 3);
    }

    /// `check_primary_auth` is called per-handler, not via middleware, so we
    /// test it as a unit rather than through the full HTTP stack.
    #[test]
    fn check_primary_auth_returns_401_without_bearer() {
        use argon2::password_hash::{rand_core::OsRng, SaltString};
        use argon2::{Argon2, PasswordHasher};

        use crate::registry::{KeyEntry, Role};

        let salt = SaltString::generate(&mut OsRng);
        let hash = Argon2::default()
            .hash_password(b"s3cr3t", &salt)
            .unwrap()
            .to_string();

        let state = test_state(std::path::PathBuf::from("/tmp/test-project"));
        state.registry.primary().set_keys(vec![KeyEntry {
            role: Role::Admin,
            hash,
            label: String::new(),
        }]);

        let client_ip = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
        let headers = axum::http::HeaderMap::new();

        let result = state.check_primary_auth(&headers, client_ip);
        assert!(result.is_some(), "must reject missing Bearer");
        let resp = result.unwrap();
        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
    }

    #[test]
    fn check_primary_auth_accepts_correct_bearer() {
        use argon2::password_hash::{rand_core::OsRng, SaltString};
        use argon2::{Argon2, PasswordHasher};

        use crate::registry::{KeyEntry, Role};

        let salt = SaltString::generate(&mut OsRng);
        let hash = Argon2::default()
            .hash_password(b"s3cr3t", &salt)
            .unwrap()
            .to_string();

        let state = test_state(std::path::PathBuf::from("/tmp/test-project"));
        state.registry.primary().set_keys(vec![KeyEntry {
            role: Role::Admin,
            hash,
            label: String::new(),
        }]);

        let client_ip = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
        let mut headers = axum::http::HeaderMap::new();
        headers.insert(
            axum::http::header::AUTHORIZATION,
            "Bearer s3cr3t".parse().unwrap(),
        );

        let result = state.check_primary_auth(&headers, client_ip);
        assert!(result.is_none(), "valid credentials must pass through");
    }
}
