//! REST API handlers for the web dashboard.
//!
//! All `/api/*` routes require bearer token authentication (PairingGuard).

use super::AppState;
use crate::channels::session_backend::SessionBackend;
use axum::{
    extract::{ConnectInfo, Path, Query, State},
    http::{HeaderMap, StatusCode, header},
    response::{IntoResponse, Json},
};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;

const MASKED_SECRET: &str = "***MASKED***";

fn effective_gateway_mcp_config(config: &crate::config::Config) -> crate::config::McpConfig {
    let mut injected = config.clone();
    injected = crate::agent::kumiho::inject_kumiho(injected, false);
    injected = crate::agent::operator::inject_operator(injected, false);
    injected.mcp
}

async fn reconnect_gateway_mcp_registry(
    state: &AppState,
    config: &crate::config::Config,
) -> serde_json::Value {
    let mcp_config = effective_gateway_mcp_config(config);
    if !mcp_config.enabled || mcp_config.servers.is_empty() {
        state.replace_mcp_registry(None);
        tracing::info!("Gateway MCP registry disabled after config update");
        return serde_json::json!({
            "status": "disabled",
            "servers": 0,
            "tools": 0,
        });
    }

    tracing::info!(
        "Gateway: reconnecting MCP registry after config update — {} server(s) configured",
        mcp_config.servers.len()
    );
    match crate::tools::McpRegistry::connect_all(&mcp_config.servers).await {
        Ok(registry) => {
            let tool_names = registry.tool_names();
            let server_count = registry.server_count();
            let tool_count = registry.tool_count();
            let registry = Arc::new(registry);
            let kumiho_advanced =
                crate::agent::kumiho::registry_has_advanced_kumiho_tools(&tool_names);
            crate::agent::kumiho::warn_if_kumiho_advanced_missing(config, kumiho_advanced);
            state.replace_mcp_registry(Some(registry));
            tracing::info!(
                "Gateway MCP registry reconnected after config update — {} server(s), {} tool(s)",
                server_count,
                tool_count
            );
            serde_json::json!({
                "status": "reconnected",
                "servers": server_count,
                "tools": tool_count,
            })
        }
        Err(e) => {
            state.replace_mcp_registry(None);
            tracing::error!("Gateway MCP registry reconnect failed after config update: {e:#}");
            serde_json::json!({
                "status": "error",
                "error": e.to_string(),
            })
        }
    }
}

// ── Bearer token auth extractor ─────────────────────────────────

/// Header name used by trusted local sidecars (e.g. operator-mcp) to present
/// the gateway's own service token. Matches `api_auth_profiles.rs`.
const SERVICE_TOKEN_HEADER: &str = "X-Revka-Service-Token";

/// Extract and validate bearer token from Authorization header.
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
    headers
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|auth| auth.strip_prefix("Bearer "))
}

/// Returns true when `X-Revka-Service-Token` matches `state.service_token`.
///
/// The service token is generated by the gateway at startup and written to
/// `~/.revka/service-token`; it is the credential trusted local sidecars
/// (operator-mcp) use to call back into gated routes. Empty configured tokens
/// never authenticate.
fn service_token_matches(state: &AppState, headers: &HeaderMap) -> bool {
    let expected = state.service_token.as_ref();
    if expected.is_empty() {
        return false;
    }
    let provided = headers
        .get(SERVICE_TOKEN_HEADER)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    if provided.is_empty() {
        return false;
    }
    crate::security::pairing::constant_time_eq(provided, expected)
}

/// Verify caller credentials against PairingGuard *or* the gateway service token.
///
/// Two accepted credentials, both grant the same access to gated `/api/*` routes:
///   - `Authorization: Bearer <pairing-token>` — user-facing, issued via `POST /pair`.
///   - `X-Revka-Service-Token: <token>` — gateway's own credential for trusted
///     local sidecars (operator-mcp). The same value already gates the
///     `/api/auth/profiles/{id}/resolve` endpoint.
pub(super) fn require_auth(
    state: &AppState,
    headers: &HeaderMap,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
    if !state.pairing.require_pairing() {
        return Ok(());
    }

    let token = extract_bearer_token(headers).unwrap_or("");
    if state.pairing.is_authenticated(token) {
        return Ok(());
    }

    if service_token_matches(state, headers) {
        return Ok(());
    }

    Err((
        StatusCode::UNAUTHORIZED,
        Json(serde_json::json!({
            "error": "Unauthorized — pair first via POST /pair (Authorization: Bearer <token>) or present X-Revka-Service-Token"
        })),
    ))
}

// ── Query parameters ─────────────────────────────────────────────

#[derive(Deserialize)]
pub struct CronRunsQuery {
    pub limit: Option<u32>,
}

#[derive(Deserialize)]
pub struct CronAddBody {
    pub name: Option<String>,
    pub schedule: String,
    pub command: Option<String>,
    pub job_type: Option<String>,
    pub prompt: Option<String>,
    pub delivery: Option<crate::cron::DeliveryConfig>,
    pub session_target: Option<String>,
    pub model: Option<String>,
    pub allowed_tools: Option<Vec<String>>,
    pub delete_after_run: Option<bool>,
}

#[derive(Deserialize)]
pub struct CronPatchBody {
    pub name: Option<String>,
    pub schedule: Option<String>,
    pub command: Option<String>,
    pub prompt: Option<String>,
    pub enabled: Option<bool>,
}

#[derive(Deserialize)]
pub struct AuditQuery {
    /// Maximum number of events to return (default: 50, max: 500).
    pub limit: Option<usize>,
    /// Filter by event type (e.g. "command_execution", "auth_failure").
    pub event_type: Option<String>,
    /// Only include events after this timestamp (RFC 3339 / ISO 8601).
    pub since: Option<String>,
}

// ── Handlers ────────────────────────────────────────────────────

/// GET /api/status — system status overview
///
/// Returns minimal health info without auth (for operator liveness checks).
/// Returns full config details when authenticated.
pub async fn handle_api_status(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    let health = crate::health::snapshot();
    let authed = require_auth(&state, &headers).is_ok();

    if !authed {
        // Unauthenticated: return only health/liveness info
        let body = serde_json::json!({
            "uptime_seconds": health.uptime_seconds,
            "paired": state.pairing.is_paired(),
            "health": health,
        });
        return Json(body).into_response();
    }

    let config = state.config.lock().clone();

    let mut channels = serde_json::Map::new();

    for (channel, present) in config.channels_config.channels() {
        channels.insert(channel.name().to_string(), serde_json::Value::Bool(present));
    }

    let body = serde_json::json!({
        "provider": config.default_provider,
        "model": state.model,
        "temperature": state.temperature,
        "uptime_seconds": health.uptime_seconds,
        "gateway_port": config.gateway.port,
        "locale": "en",
        "memory_backend": if config.memory.backend == "kumiho" && config.kumiho.enabled {
            "kumiho"
        } else {
            state.mem.name()
        },
        "paired": state.pairing.is_paired(),
        "channels": channels,
        "health": health,
    });

    Json(body).into_response()
}

/// GET /api/config — current config (api_key masked)
pub async fn handle_api_config_get(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();

    // Serialize to TOML after masking sensitive fields.
    let masked_config = mask_sensitive_fields(&config);
    let toml_str = match toml::to_string_pretty(&masked_config) {
        Ok(s) => s,
        Err(e) => {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({"error": format!("Failed to serialize config: {e}")})),
            )
                .into_response();
        }
    };

    Json(serde_json::json!({
        "format": "toml",
        "content": toml_str,
    }))
    .into_response()
}

/// PUT /api/config — update config from TOML body
pub async fn handle_api_config_put(
    State(state): State<AppState>,
    headers: HeaderMap,
    body: String,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    // Parse the incoming TOML
    let incoming: crate::config::Config = match toml::from_str(&body) {
        Ok(c) => c,
        Err(e) => {
            return (
                StatusCode::BAD_REQUEST,
                Json(serde_json::json!({"error": format!("Invalid TOML: {e}")})),
            )
                .into_response();
        }
    };

    let current_config = state.config.lock().clone();
    let new_config = hydrate_config_for_save(incoming, &current_config);

    if let Err(e) = new_config.validate() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": format!("Invalid config: {e}")})),
        )
            .into_response();
    }

    // Save to disk
    if let Err(e) = new_config.save().await {
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
        )
            .into_response();
    }

    // Update in-memory config, then reconnect MCP sidecars from the effective
    // injected config so stdio env changes take effect without a daemon restart.
    *state.config.lock() = new_config.clone();
    let mcp_registry = reconnect_gateway_mcp_registry(&state, &new_config).await;

    // Audit log the config change
    if let Some(ref logger) = state.audit_logger {
        let _ = logger.log_config_change("dashboard", "Configuration updated via REST API");
    }

    // The in-memory swap above only reaches agents created after this point;
    // daemon components (gateway server, channels, provider stack) capture
    // config at startup. When a service supervisor will revive us, schedule a
    // graceful self-restart so the saved config is fully applied.
    let restart = schedule_supervised_restart();

    Json(serde_json::json!({"status": "ok", "mcp_registry": mcp_registry, "restart": restart}))
        .into_response()
}

/// Whether a service supervisor configured to auto-restart this process is
/// present: launchd (KeepAlive) sets `XPC_SERVICE_NAME`, systemd
/// (Restart=always) sets `INVOCATION_ID` — both written by
/// `revka service install`.
fn supervisor_detected(xpc_service_name: bool, invocation_id: bool) -> bool {
    xpc_service_name || invocation_id
}

/// After a successful full-config save, restart the daemon by sending
/// ourselves SIGTERM (the daemon's graceful shutdown path); the service
/// supervisor revives it with the new config. Without a supervisor the
/// process would just die, so fall back to the in-memory hot-apply.
fn schedule_supervised_restart() -> &'static str {
    if !supervisor_detected(
        std::env::var_os("XPC_SERVICE_NAME").is_some(),
        std::env::var_os("INVOCATION_ID").is_some(),
    ) {
        return "skipped — not running under a service manager; run `revka service restart` to fully apply";
    }
    #[cfg(unix)]
    {
        tokio::spawn(async {
            // Let the HTTP response reach the client before going down.
            tokio::time::sleep(std::time::Duration::from_millis(750)).await;
            tracing::info!(
                "Config saved via REST API — restarting daemon to apply it (supervisor will revive)"
            );
            // SAFETY: signalling our own pid; SIGTERM is handled by the
            // daemon's graceful shutdown listener.
            unsafe {
                libc::kill(libc::getpid(), libc::SIGTERM);
            }
        });
        "scheduled"
    }
    #[cfg(not(unix))]
    {
        "skipped — automatic restart is not supported on this platform"
    }
}

/// GET /api/tools — list registered tool specs
pub async fn handle_api_tools(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let tools: Vec<serde_json::Value> = state
        .tools_registry
        .iter()
        .map(|spec| {
            serde_json::json!({
                "name": spec.name,
                "description": spec.description,
                "parameters": spec.parameters,
            })
        })
        .collect();

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

/// GET /api/cron — list cron jobs
pub async fn handle_api_cron_list(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();
    match crate::cron::list_jobs(&config) {
        Ok(jobs) => Json(serde_json::json!({"jobs": jobs})).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to list cron jobs: {e}")})),
        )
            .into_response(),
    }
}

/// POST /api/cron — add a new cron job
pub async fn handle_api_cron_add(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(body): Json<CronAddBody>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let CronAddBody {
        name,
        schedule,
        command,
        job_type,
        prompt,
        delivery,
        session_target,
        model,
        allowed_tools,
        delete_after_run,
    } = body;

    let config = state.config.lock().clone();
    let schedule = crate::cron::Schedule::Cron {
        expr: schedule,
        tz: None,
    };
    if let Err(e) = crate::cron::validate_delivery_config(delivery.as_ref()) {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": format!("Failed to add cron job: {e}")})),
        )
            .into_response();
    }

    // Determine job type: explicit field, or infer "agent" when prompt is provided.
    let is_agent =
        matches!(job_type.as_deref(), Some("agent")) || (job_type.is_none() && prompt.is_some());

    let result = if is_agent {
        let prompt = match prompt.as_deref() {
            Some(p) if !p.trim().is_empty() => p,
            _ => {
                return (
                    StatusCode::BAD_REQUEST,
                    Json(serde_json::json!({"error": "Missing 'prompt' for agent job"})),
                )
                    .into_response();
            }
        };

        let session_target = session_target
            .as_deref()
            .map(crate::cron::SessionTarget::parse)
            .unwrap_or_default();

        let default_delete = matches!(schedule, crate::cron::Schedule::At { .. });
        let delete_after_run = delete_after_run.unwrap_or(default_delete);

        crate::cron::add_agent_job(
            &config,
            name,
            schedule,
            prompt,
            session_target,
            model,
            delivery,
            delete_after_run,
            allowed_tools,
        )
    } else {
        let command = match command.as_deref() {
            Some(c) if !c.trim().is_empty() => c,
            _ => {
                return (
                    StatusCode::BAD_REQUEST,
                    Json(serde_json::json!({"error": "Missing 'command' for shell job"})),
                )
                    .into_response();
            }
        };

        crate::cron::add_shell_job_with_approval(&config, name, schedule, command, delivery, false)
    };

    match result {
        Ok(job) => Json(serde_json::json!({"status": "ok", "job": job})).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to add cron job: {e}")})),
        )
            .into_response(),
    }
}

/// GET /api/cron/:id/runs — list recent runs for a cron job
pub async fn handle_api_cron_runs(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<String>,
    Query(params): Query<CronRunsQuery>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let limit = params.limit.unwrap_or(20).clamp(1, 100) as usize;
    let config = state.config.lock().clone();

    // Verify the job exists before listing runs.
    if let Err(e) = crate::cron::get_job(&config, &id) {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("Cron job not found: {e}")})),
        )
            .into_response();
    }

    match crate::cron::list_runs(&config, &id, limit) {
        Ok(runs) => {
            let runs_json: Vec<serde_json::Value> = runs
                .iter()
                .map(|r| {
                    serde_json::json!({
                        "id": r.id,
                        "job_id": r.job_id,
                        "started_at": r.started_at.to_rfc3339(),
                        "finished_at": r.finished_at.to_rfc3339(),
                        "status": r.status,
                        "output": r.output,
                        "duration_ms": r.duration_ms,
                    })
                })
                .collect();
            Json(serde_json::json!({"runs": runs_json})).into_response()
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to list cron runs: {e}")})),
        )
            .into_response(),
    }
}

/// PATCH /api/cron/:id — update an existing cron job
pub async fn handle_api_cron_patch(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<String>,
    Json(body): Json<CronPatchBody>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();

    // Build the schedule from the provided expression string (if any).
    let schedule = match body.schedule {
        Some(expr) if !expr.trim().is_empty() => Some(crate::cron::Schedule::Cron {
            expr: expr.trim().to_string(),
            tz: None,
        }),
        _ => None,
    };

    // Route the edited text to the correct field based on the job's stored type.
    // The frontend sends a single textarea value; for agent jobs it is the prompt,
    // for shell jobs it is the command.
    let existing = match crate::cron::get_job(&config, &id) {
        Ok(j) => j,
        Err(e) => {
            return (
                StatusCode::NOT_FOUND,
                Json(serde_json::json!({"error": format!("Cron job not found: {e}")})),
            )
                .into_response();
        }
    };
    let is_agent = matches!(existing.job_type, crate::cron::JobType::Agent);
    let (patch_command, patch_prompt) = if is_agent {
        (None, body.command.or(body.prompt))
    } else {
        (body.command.or(body.prompt), None)
    };

    let patch = crate::cron::CronJobPatch {
        name: body.name,
        schedule,
        command: patch_command,
        prompt: patch_prompt,
        enabled: body.enabled,
        ..crate::cron::CronJobPatch::default()
    };

    match crate::cron::update_shell_job_with_approval(&config, &id, patch, false) {
        Ok(job) => Json(serde_json::json!({"status": "ok", "job": job})).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to update cron job: {e}")})),
        )
            .into_response(),
    }
}

/// DELETE /api/cron/:id — remove a cron job
pub async fn handle_api_cron_delete(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<String>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();
    match crate::cron::remove_job(&config, &id) {
        Ok(()) => Json(serde_json::json!({"status": "ok"})).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to remove cron job: {e}")})),
        )
            .into_response(),
    }
}

/// GET /api/cron/settings — return cron subsystem settings
pub async fn handle_api_cron_settings_get(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();
    Json(serde_json::json!({
        "enabled": config.cron.enabled,
        "catch_up_on_startup": config.cron.catch_up_on_startup,
        "max_run_history": config.cron.max_run_history,
    }))
    .into_response()
}

/// PATCH /api/cron/settings — update cron subsystem settings
pub async fn handle_api_cron_settings_patch(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let mut config = state.config.lock().clone();

    if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
        config.cron.enabled = v;
    }
    if let Some(v) = body.get("catch_up_on_startup").and_then(|v| v.as_bool()) {
        config.cron.catch_up_on_startup = v;
    }
    if let Some(v) = body.get("max_run_history").and_then(|v| v.as_u64()) {
        config.cron.max_run_history = u32::try_from(v).unwrap_or(u32::MAX);
    }

    if let Err(e) = config.save().await {
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
        )
            .into_response();
    }

    *state.config.lock() = config.clone();

    Json(serde_json::json!({
        "status": "ok",
        "enabled": config.cron.enabled,
        "catch_up_on_startup": config.cron.catch_up_on_startup,
        "max_run_history": config.cron.max_run_history,
    }))
    .into_response()
}

/// GET /api/integrations — list all integrations with status
pub async fn handle_api_integrations(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();
    let entries = crate::integrations::registry::all_integrations();

    let integrations: Vec<serde_json::Value> = entries
        .iter()
        .map(|entry| {
            let status = (entry.status_fn)(&config);
            serde_json::json!({
                "name": entry.name,
                "description": entry.description,
                "category": entry.category,
                "status": status,
            })
        })
        .collect();

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

/// GET /api/integrations/settings — return per-integration settings (enabled + category)
pub async fn handle_api_integrations_settings(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();
    let entries = crate::integrations::registry::all_integrations();

    let mut settings = serde_json::Map::new();
    for entry in &entries {
        let status = (entry.status_fn)(&config);
        let enabled = matches!(status, crate::integrations::IntegrationStatus::Active);
        settings.insert(
            entry.name.to_string(),
            serde_json::json!({
                "enabled": enabled,
                "category": entry.category,
                "status": status,
            }),
        );
    }

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

/// POST /api/doctor — run diagnostics
pub async fn handle_api_doctor(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();
    let results = crate::doctor::diagnose(&config);

    let ok_count = results
        .iter()
        .filter(|r| r.severity == crate::doctor::Severity::Ok)
        .count();
    let warn_count = results
        .iter()
        .filter(|r| r.severity == crate::doctor::Severity::Warn)
        .count();
    let error_count = results
        .iter()
        .filter(|r| r.severity == crate::doctor::Severity::Error)
        .count();

    Json(serde_json::json!({
        "results": results,
        "summary": {
            "ok": ok_count,
            "warnings": warn_count,
            "errors": error_count,
        }
    }))
    .into_response()
}

// Old /api/memory CRUD handlers removed — use Kumiho memory via /api/memory/graph.

/// GET /api/cost — cost summary
///
/// No auth required — read-only telemetry used by the operator and dashboard.
pub async fn handle_api_cost(
    State(state): State<AppState>,
    _headers: HeaderMap,
) -> impl IntoResponse {
    if let Some(ref tracker) = state.cost_tracker {
        match tracker.get_summary() {
            Ok(summary) => Json(serde_json::json!({"cost": summary})).into_response(),
            Err(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({"error": format!("Cost summary failed: {e}")})),
            )
                .into_response(),
        }
    } else {
        Json(serde_json::json!({
            "cost": {
                "session_cost_usd": 0.0,
                "daily_cost_usd": 0.0,
                "monthly_cost_usd": 0.0,
                "total_tokens": 0,
                "request_count": 0,
                "by_model": {},
                "by_agent": {},
                "by_source": {},
                "budget": {
                    "enabled": false,
                    "daily_limit_usd": 0.0,
                    "monthly_limit_usd": 0.0,
                    "warn_at_percent": 0,
                    "daily_remaining_usd": 0.0,
                    "monthly_remaining_usd": 0.0,
                    "daily_percent": 0.0,
                    "monthly_percent": 0.0,
                    "state": "disabled",
                },
            }
        }))
        .into_response()
    }
}

#[derive(Debug, Deserialize)]
pub struct CostUsageIngestRequest {
    pub model: String,
    #[serde(default)]
    pub provider: Option<String>,
    #[serde(default)]
    pub input_tokens: Option<u64>,
    #[serde(default)]
    pub output_tokens: Option<u64>,
    #[serde(default)]
    pub source: Option<String>,
    #[serde(default)]
    pub agent_id: Option<String>,
    #[serde(default)]
    pub agent_title: Option<String>,
}

/// POST /api/cost/usage — trusted sidecar/runtime token-usage ingestion.
///
/// Requires the gateway service token because this endpoint mutates the
/// process-global cost ledger used for budget enforcement.
pub async fn handle_api_cost_usage(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(payload): Json<CostUsageIngestRequest>,
) -> impl IntoResponse {
    if !service_token_matches(&state, &headers) {
        return (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({
                "error": "Unauthorized — present X-Revka-Service-Token"
            })),
        )
            .into_response();
    }

    let model = payload.model.trim();
    if model.is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": "model is required"})),
        )
            .into_response();
    }

    let Some(ref tracker) = state.cost_tracker else {
        return Json(serde_json::json!({
            "recorded": false,
            "reason": "cost tracking disabled"
        }))
        .into_response();
    };

    let provider = payload
        .provider
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("sidecar");
    let source = payload
        .source
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("sidecar");
    let metadata = crate::cost::types::CostRecordMetadata {
        source: Some(source.to_string()),
        provider: Some(provider.to_string()),
        agent_id: payload
            .agent_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(str::to_string),
        agent_title: payload
            .agent_title
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(str::to_string),
    };

    match tracker.record_usage_from_tokens(
        provider,
        model,
        payload.input_tokens.unwrap_or(0),
        payload.output_tokens.unwrap_or(0),
        metadata,
    ) {
        Ok(usage) => Json(serde_json::json!({
            "recorded": true,
            "usage": usage,
        }))
        .into_response(),
        Err(error) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Cost usage ingest failed: {error}")})),
        )
            .into_response(),
    }
}

/// GET /api/audit — recent audit trail events
pub async fn handle_api_audit(
    State(state): State<AppState>,
    headers: HeaderMap,
    Query(params): Query<AuditQuery>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let Some(ref logger) = state.audit_logger else {
        return Json(serde_json::json!({
            "events": [],
            "count": 0,
            "audit_enabled": false,
        }))
        .into_response();
    };

    let limit = params.limit.unwrap_or(50).min(500);
    let event_type = params.event_type.as_deref();
    let since = params.since.as_deref().and_then(|s| {
        chrono::DateTime::parse_from_rfc3339(s)
            .ok()
            .map(|dt| dt.with_timezone(&chrono::Utc))
    });

    match logger.read_events(limit, event_type, since) {
        Ok(events) => {
            let count = events.len();
            Json(serde_json::json!({
                "events": events,
                "count": count,
                "audit_enabled": true,
            }))
            .into_response()
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Audit read failed: {e}")})),
        )
            .into_response(),
    }
}

/// GET /api/audit/verify — verify audit chain integrity
pub async fn handle_api_audit_verify(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let Some(ref logger) = state.audit_logger else {
        return Json(serde_json::json!({
            "verified": false,
            "error": "Audit logging not enabled",
        }))
        .into_response();
    };

    match crate::security::audit::verify_chain(logger.log_path()) {
        Ok(count) => Json(serde_json::json!({
            "verified": true,
            "entry_count": count,
        }))
        .into_response(),
        Err(e) => Json(serde_json::json!({
            "verified": false,
            "error": format!("{e}"),
        }))
        .into_response(),
    }
}

/// GET /api/cli-tools — discovered CLI tools
pub async fn handle_api_cli_tools(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let tools = crate::tools::cli_discovery::discover_cli_tools(&[], &[]);

    Json(serde_json::json!({"cli_tools": tools})).into_response()
}

/// GET /api/health — component health snapshot
pub async fn handle_api_health(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let snapshot = crate::health::snapshot();
    Json(serde_json::json!({"health": snapshot})).into_response()
}

// ── Node API (P3.2 multi-node operator) ───────────────────────

/// GET /api/nodes — list connected nodes and their capabilities
pub async fn handle_api_nodes(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let caps = state.node_registry.all_capabilities();
    let node_ids = state.node_registry.node_ids();

    // Group capabilities by node
    let mut nodes: std::collections::HashMap<String, Vec<serde_json::Value>> =
        std::collections::HashMap::new();
    for (node_id, cap_name, cap) in &caps {
        nodes
            .entry(node_id.clone())
            .or_default()
            .push(serde_json::json!({
                "name": cap_name,
                "description": cap.description,
            }));
    }

    let node_list: Vec<serde_json::Value> = node_ids
        .iter()
        .map(|id| {
            let capabilities = nodes.get(id).cloned().unwrap_or_default();
            serde_json::json!({
                "node_id": id,
                "capabilities": capabilities,
                "capability_count": capabilities.len(),
            })
        })
        .collect();

    Json(serde_json::json!({
        "nodes": node_list,
        "count": node_list.len(),
    }))
    .into_response()
}

/// POST /api/nodes/:node_id/invoke — invoke a capability on a specific node
pub async fn handle_api_node_invoke(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(node_id): Path<String>,
    Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let capability = body
        .get("capability")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    if capability.is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": "capability is required"})),
        )
            .into_response();
    }

    let args = body.get("args").cloned().unwrap_or(serde_json::json!({}));

    let Some(invoke_tx) = state.node_registry.invoke_tx(&node_id) else {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("Node not found: {node_id}")})),
        )
            .into_response();
    };

    let call_id = Uuid::new_v4().to_string();
    let (response_tx, response_rx) = tokio::sync::oneshot::channel();

    let invocation = super::nodes::NodeInvocation {
        call_id: call_id.clone(),
        capability: capability.to_string(),
        args,
        response_tx,
    };

    if invoke_tx.send(invocation).await.is_err() {
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": "Failed to send to node"})),
        )
            .into_response();
    }

    match tokio::time::timeout(std::time::Duration::from_secs(30), response_rx).await {
        Ok(Ok(result)) => Json(serde_json::json!({
            "call_id": call_id,
            "success": result.success,
            "output": result.output,
            "error": result.error,
        }))
        .into_response(),
        Ok(Err(_)) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": "Node dropped the response channel"})),
        )
            .into_response(),
        Err(_) => (
            StatusCode::GATEWAY_TIMEOUT,
            Json(serde_json::json!({"error": "Node invocation timed out (30s)"})),
        )
            .into_response(),
    }
}

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

fn is_masked_secret(value: &str) -> bool {
    value == MASKED_SECRET
}

fn mask_optional_secret(value: &mut Option<String>) {
    if value.is_some() {
        *value = Some(MASKED_SECRET.to_string());
    }
}

fn mask_required_secret(value: &mut String) {
    if !value.is_empty() {
        *value = MASKED_SECRET.to_string();
    }
}

fn mask_vec_secrets(values: &mut [String]) {
    for value in values.iter_mut() {
        if !value.is_empty() {
            *value = MASKED_SECRET.to_string();
        }
    }
}

#[allow(clippy::ref_option)]
fn restore_optional_secret(value: &mut Option<String>, current: &Option<String>) {
    if value.as_deref().is_some_and(is_masked_secret) {
        *value = current.clone();
    }
}

fn restore_required_secret(value: &mut String, current: &str) {
    if is_masked_secret(value) {
        *value = current.to_string();
    }
}

fn restore_vec_secrets(values: &mut [String], current: &[String]) {
    for (idx, value) in values.iter_mut().enumerate() {
        if is_masked_secret(value) {
            if let Some(existing) = current.get(idx) {
                *value = existing.clone();
            }
        }
    }
}

fn normalize_route_field(value: &str) -> String {
    value.trim().to_ascii_lowercase()
}

fn model_route_identity_matches(
    incoming: &crate::config::schema::ModelRouteConfig,
    current: &crate::config::schema::ModelRouteConfig,
) -> bool {
    normalize_route_field(&incoming.hint) == normalize_route_field(&current.hint)
        && normalize_route_field(&incoming.provider) == normalize_route_field(&current.provider)
        && normalize_route_field(&incoming.model) == normalize_route_field(&current.model)
}

fn model_route_provider_model_matches(
    incoming: &crate::config::schema::ModelRouteConfig,
    current: &crate::config::schema::ModelRouteConfig,
) -> bool {
    normalize_route_field(&incoming.provider) == normalize_route_field(&current.provider)
        && normalize_route_field(&incoming.model) == normalize_route_field(&current.model)
}

fn embedding_route_identity_matches(
    incoming: &crate::config::schema::EmbeddingRouteConfig,
    current: &crate::config::schema::EmbeddingRouteConfig,
) -> bool {
    normalize_route_field(&incoming.hint) == normalize_route_field(&current.hint)
        && normalize_route_field(&incoming.provider) == normalize_route_field(&current.provider)
        && normalize_route_field(&incoming.model) == normalize_route_field(&current.model)
}

fn embedding_route_provider_model_matches(
    incoming: &crate::config::schema::EmbeddingRouteConfig,
    current: &crate::config::schema::EmbeddingRouteConfig,
) -> bool {
    normalize_route_field(&incoming.provider) == normalize_route_field(&current.provider)
        && normalize_route_field(&incoming.model) == normalize_route_field(&current.model)
}

fn restore_model_route_api_keys(
    incoming: &mut [crate::config::schema::ModelRouteConfig],
    current: &[crate::config::schema::ModelRouteConfig],
) {
    let mut used_current = vec![false; current.len()];
    for incoming_route in incoming {
        if !incoming_route
            .api_key
            .as_deref()
            .is_some_and(is_masked_secret)
        {
            continue;
        }

        let exact_match_idx = current
            .iter()
            .enumerate()
            .find(|(idx, current_route)| {
                !used_current[*idx] && model_route_identity_matches(incoming_route, current_route)
            })
            .map(|(idx, _)| idx);

        let match_idx = exact_match_idx.or_else(|| {
            current
                .iter()
                .enumerate()
                .find(|(idx, current_route)| {
                    !used_current[*idx]
                        && model_route_provider_model_matches(incoming_route, current_route)
                })
                .map(|(idx, _)| idx)
        });

        if let Some(idx) = match_idx {
            used_current[idx] = true;
            incoming_route.api_key = current[idx].api_key.clone();
        } else {
            // Never persist UI placeholders to disk when no safe restore target exists.
            incoming_route.api_key = None;
        }
    }
}

fn restore_embedding_route_api_keys(
    incoming: &mut [crate::config::schema::EmbeddingRouteConfig],
    current: &[crate::config::schema::EmbeddingRouteConfig],
) {
    let mut used_current = vec![false; current.len()];
    for incoming_route in incoming {
        if !incoming_route
            .api_key
            .as_deref()
            .is_some_and(is_masked_secret)
        {
            continue;
        }

        let exact_match_idx = current
            .iter()
            .enumerate()
            .find(|(idx, current_route)| {
                !used_current[*idx]
                    && embedding_route_identity_matches(incoming_route, current_route)
            })
            .map(|(idx, _)| idx);

        let match_idx = exact_match_idx.or_else(|| {
            current
                .iter()
                .enumerate()
                .find(|(idx, current_route)| {
                    !used_current[*idx]
                        && embedding_route_provider_model_matches(incoming_route, current_route)
                })
                .map(|(idx, _)| idx)
        });

        if let Some(idx) = match_idx {
            used_current[idx] = true;
            incoming_route.api_key = current[idx].api_key.clone();
        } else {
            // Never persist UI placeholders to disk when no safe restore target exists.
            incoming_route.api_key = None;
        }
    }
}

fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Config {
    let mut masked = config.clone();

    mask_optional_secret(&mut masked.api_key);
    mask_vec_secrets(&mut masked.reliability.api_keys);
    mask_vec_secrets(&mut masked.gateway.paired_tokens);
    mask_optional_secret(&mut masked.composio.api_key);
    mask_optional_secret(&mut masked.browser.computer_use.api_key);
    mask_optional_secret(&mut masked.web_search.brave_api_key);
    mask_optional_secret(&mut masked.storage.provider.config.db_url);
    // qdrant.api_key removed (backend removed in Revka)
    if let Some(cloudflare) = masked.tunnel.cloudflare.as_mut() {
        mask_required_secret(&mut cloudflare.token);
    }
    if let Some(ngrok) = masked.tunnel.ngrok.as_mut() {
        mask_required_secret(&mut ngrok.auth_token);
    }

    for agent in masked.agents.values_mut() {
        mask_optional_secret(&mut agent.api_key);
    }
    for route in &mut masked.model_routes {
        mask_optional_secret(&mut route.api_key);
    }
    for route in &mut masked.embedding_routes {
        mask_optional_secret(&mut route.api_key);
    }

    if let Some(telegram) = masked.channels_config.telegram.as_mut() {
        mask_required_secret(&mut telegram.bot_token);
    }
    if let Some(discord) = masked.channels_config.discord.as_mut() {
        mask_required_secret(&mut discord.bot_token);
    }
    if let Some(slack) = masked.channels_config.slack.as_mut() {
        mask_required_secret(&mut slack.bot_token);
        mask_optional_secret(&mut slack.app_token);
    }
    if let Some(mattermost) = masked.channels_config.mattermost.as_mut() {
        mask_required_secret(&mut mattermost.bot_token);
    }
    if let Some(webhook) = masked.channels_config.webhook.as_mut() {
        mask_optional_secret(&mut webhook.secret);
    }
    if let Some(matrix) = masked.channels_config.matrix.as_mut() {
        mask_required_secret(&mut matrix.access_token);
    }
    if let Some(whatsapp) = masked.channels_config.whatsapp.as_mut() {
        mask_optional_secret(&mut whatsapp.access_token);
        mask_optional_secret(&mut whatsapp.app_secret);
        mask_optional_secret(&mut whatsapp.verify_token);
    }
    if let Some(linq) = masked.channels_config.linq.as_mut() {
        mask_required_secret(&mut linq.api_token);
        mask_optional_secret(&mut linq.signing_secret);
    }
    if let Some(nextcloud) = masked.channels_config.nextcloud_talk.as_mut() {
        mask_required_secret(&mut nextcloud.app_token);
        mask_optional_secret(&mut nextcloud.webhook_secret);
    }
    if let Some(wati) = masked.channels_config.wati.as_mut() {
        mask_required_secret(&mut wati.api_token);
    }
    if let Some(irc) = masked.channels_config.irc.as_mut() {
        mask_optional_secret(&mut irc.server_password);
        mask_optional_secret(&mut irc.nickserv_password);
        mask_optional_secret(&mut irc.sasl_password);
    }
    if let Some(lark) = masked.channels_config.lark.as_mut() {
        mask_required_secret(&mut lark.app_secret);
        mask_optional_secret(&mut lark.encrypt_key);
        mask_optional_secret(&mut lark.verification_token);
    }
    if let Some(feishu) = masked.channels_config.feishu.as_mut() {
        mask_required_secret(&mut feishu.app_secret);
        mask_optional_secret(&mut feishu.encrypt_key);
        mask_optional_secret(&mut feishu.verification_token);
    }
    if let Some(dingtalk) = masked.channels_config.dingtalk.as_mut() {
        mask_required_secret(&mut dingtalk.client_secret);
    }
    if let Some(qq) = masked.channels_config.qq.as_mut() {
        mask_required_secret(&mut qq.app_secret);
    }
    #[cfg(feature = "channel-nostr")]
    if let Some(nostr) = masked.channels_config.nostr.as_mut() {
        mask_required_secret(&mut nostr.private_key);
    }
    if let Some(clawdtalk) = masked.channels_config.clawdtalk.as_mut() {
        mask_required_secret(&mut clawdtalk.api_key);
        mask_optional_secret(&mut clawdtalk.webhook_secret);
    }
    if let Some(email) = masked.channels_config.email.as_mut() {
        mask_required_secret(&mut email.password);
    }
    mask_optional_secret(&mut masked.transcription.api_key);
    mask_optional_secret(&mut masked.clawhub.api_token);
    masked
}

fn restore_masked_sensitive_fields(
    incoming: &mut crate::config::Config,
    current: &crate::config::Config,
) {
    restore_optional_secret(&mut incoming.api_key, &current.api_key);
    restore_vec_secrets(
        &mut incoming.gateway.paired_tokens,
        &current.gateway.paired_tokens,
    );
    restore_vec_secrets(
        &mut incoming.reliability.api_keys,
        &current.reliability.api_keys,
    );
    restore_optional_secret(&mut incoming.composio.api_key, &current.composio.api_key);
    restore_optional_secret(
        &mut incoming.browser.computer_use.api_key,
        &current.browser.computer_use.api_key,
    );
    restore_optional_secret(
        &mut incoming.web_search.brave_api_key,
        &current.web_search.brave_api_key,
    );
    restore_optional_secret(
        &mut incoming.storage.provider.config.db_url,
        &current.storage.provider.config.db_url,
    );
    // qdrant.api_key removed (backend removed in Revka)
    if let (Some(incoming_tunnel), Some(current_tunnel)) = (
        incoming.tunnel.cloudflare.as_mut(),
        current.tunnel.cloudflare.as_ref(),
    ) {
        restore_required_secret(&mut incoming_tunnel.token, &current_tunnel.token);
    }
    if let (Some(incoming_tunnel), Some(current_tunnel)) = (
        incoming.tunnel.ngrok.as_mut(),
        current.tunnel.ngrok.as_ref(),
    ) {
        restore_required_secret(&mut incoming_tunnel.auth_token, &current_tunnel.auth_token);
    }

    for (name, agent) in &mut incoming.agents {
        if let Some(current_agent) = current.agents.get(name) {
            restore_optional_secret(&mut agent.api_key, &current_agent.api_key);
        }
    }
    restore_model_route_api_keys(&mut incoming.model_routes, &current.model_routes);
    restore_embedding_route_api_keys(&mut incoming.embedding_routes, &current.embedding_routes);

    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.telegram.as_mut(),
        current.channels_config.telegram.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.bot_token, &current_ch.bot_token);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.discord.as_mut(),
        current.channels_config.discord.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.bot_token, &current_ch.bot_token);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.slack.as_mut(),
        current.channels_config.slack.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.bot_token, &current_ch.bot_token);
        restore_optional_secret(&mut incoming_ch.app_token, &current_ch.app_token);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.mattermost.as_mut(),
        current.channels_config.mattermost.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.bot_token, &current_ch.bot_token);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.webhook.as_mut(),
        current.channels_config.webhook.as_ref(),
    ) {
        restore_optional_secret(&mut incoming_ch.secret, &current_ch.secret);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.matrix.as_mut(),
        current.channels_config.matrix.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.access_token, &current_ch.access_token);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.whatsapp.as_mut(),
        current.channels_config.whatsapp.as_ref(),
    ) {
        restore_optional_secret(&mut incoming_ch.access_token, &current_ch.access_token);
        restore_optional_secret(&mut incoming_ch.app_secret, &current_ch.app_secret);
        restore_optional_secret(&mut incoming_ch.verify_token, &current_ch.verify_token);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.linq.as_mut(),
        current.channels_config.linq.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.api_token, &current_ch.api_token);
        restore_optional_secret(&mut incoming_ch.signing_secret, &current_ch.signing_secret);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.nextcloud_talk.as_mut(),
        current.channels_config.nextcloud_talk.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.app_token, &current_ch.app_token);
        restore_optional_secret(&mut incoming_ch.webhook_secret, &current_ch.webhook_secret);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.wati.as_mut(),
        current.channels_config.wati.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.api_token, &current_ch.api_token);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.irc.as_mut(),
        current.channels_config.irc.as_ref(),
    ) {
        restore_optional_secret(
            &mut incoming_ch.server_password,
            &current_ch.server_password,
        );
        restore_optional_secret(
            &mut incoming_ch.nickserv_password,
            &current_ch.nickserv_password,
        );
        restore_optional_secret(&mut incoming_ch.sasl_password, &current_ch.sasl_password);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.lark.as_mut(),
        current.channels_config.lark.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.app_secret, &current_ch.app_secret);
        restore_optional_secret(&mut incoming_ch.encrypt_key, &current_ch.encrypt_key);
        restore_optional_secret(
            &mut incoming_ch.verification_token,
            &current_ch.verification_token,
        );
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.feishu.as_mut(),
        current.channels_config.feishu.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.app_secret, &current_ch.app_secret);
        restore_optional_secret(&mut incoming_ch.encrypt_key, &current_ch.encrypt_key);
        restore_optional_secret(
            &mut incoming_ch.verification_token,
            &current_ch.verification_token,
        );
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.dingtalk.as_mut(),
        current.channels_config.dingtalk.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.client_secret, &current_ch.client_secret);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.qq.as_mut(),
        current.channels_config.qq.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.app_secret, &current_ch.app_secret);
    }
    #[cfg(feature = "channel-nostr")]
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.nostr.as_mut(),
        current.channels_config.nostr.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.private_key, &current_ch.private_key);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.clawdtalk.as_mut(),
        current.channels_config.clawdtalk.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.api_key, &current_ch.api_key);
        restore_optional_secret(&mut incoming_ch.webhook_secret, &current_ch.webhook_secret);
    }
    if let (Some(incoming_ch), Some(current_ch)) = (
        incoming.channels_config.email.as_mut(),
        current.channels_config.email.as_ref(),
    ) {
        restore_required_secret(&mut incoming_ch.password, &current_ch.password);
    }
    restore_optional_secret(
        &mut incoming.transcription.api_key,
        &current.transcription.api_key,
    );
    restore_optional_secret(&mut incoming.clawhub.api_token, &current.clawhub.api_token);
}

fn hydrate_config_for_save(
    mut incoming: crate::config::Config,
    current: &crate::config::Config,
) -> crate::config::Config {
    restore_masked_sensitive_fields(&mut incoming, current);
    // These are runtime-computed fields skipped from TOML serialization.
    incoming.config_path = current.config_path.clone();
    incoming.workspace_dir = current.workspace_dir.clone();
    incoming
}

// ── Session API handlers ─────────────────────────────────────────

const GATEWAY_SESSION_KEY_PREFIX: &str = "gw_";
const DASHBOARD_SESSION_CHANNEL: &str = "dashboard";
const CHANNEL_SESSION_PREFIXES: &[(&str, &str)] = &[
    ("nextcloud_talk_", "nextcloud_talk"),
    ("discord_", "discord"),
    ("telegram_", "telegram"),
    ("slack_", "slack"),
    ("matrix_", "matrix"),
    ("mattermost_", "mattermost"),
    ("whatsapp_", "whatsapp"),
    ("linq_", "linq"),
    ("wati_", "wati"),
    ("lark_", "lark"),
    ("qq_", "qq"),
];

fn dashboard_session_id_from_key(session_key: &str) -> Option<&str> {
    session_key.strip_prefix(GATEWAY_SESSION_KEY_PREFIX)
}

fn gateway_session_key(id: &str) -> String {
    format!("{GATEWAY_SESSION_KEY_PREFIX}{id}")
}

fn channel_from_session_key(session_key: &str) -> &str {
    for (prefix, channel) in CHANNEL_SESSION_PREFIXES {
        if session_key.starts_with(prefix) {
            return channel;
        }
    }

    session_key
        .split_once('_')
        .map(|(channel, _)| channel)
        .unwrap_or("channel")
}

fn session_status(
    now: chrono::DateTime<chrono::Utc>,
    last_activity: chrono::DateTime<chrono::Utc>,
) -> &'static str {
    if (now - last_activity).num_minutes() < 5 {
        "active"
    } else {
        "idle"
    }
}

/// GET /api/sessions — list dashboard chat sessions
pub async fn handle_api_sessions_list(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let Some(ref backend) = state.session_backend else {
        return Json(serde_json::json!({
            "sessions": [],
            "message": "Session persistence is disabled"
        }))
        .into_response();
    };

    let now = chrono::Utc::now();
    let all_metadata = backend.list_sessions_with_metadata();
    let mut sessions: Vec<serde_json::Value> = all_metadata
        .into_iter()
        .filter_map(|meta| {
            let id = dashboard_session_id_from_key(&meta.key)?;
            let mut entry = serde_json::json!({
                "id": id,
                "channel": DASHBOARD_SESSION_CHANNEL,
                "started_at": meta.created_at.to_rfc3339(),
                "last_activity": meta.last_activity.to_rfc3339(),
                "status": session_status(now, meta.last_activity),
                "message_count": meta.message_count,
            });
            if let Some(name) = meta.name {
                entry["name"] = serde_json::Value::String(name);
            }
            Some(entry)
        })
        .collect();

    let (workspace_dir, channel_session_persistence) = {
        let config = state.config.lock();
        (
            config.workspace_dir.clone(),
            config.channels_config.session_persistence,
        )
    };
    if channel_session_persistence
        && let Ok(channel_store) = crate::channels::session_store::SessionStore::new(&workspace_dir)
    {
        sessions.extend(
            channel_store
                .list_sessions_with_metadata()
                .into_iter()
                .filter(|meta| dashboard_session_id_from_key(&meta.key).is_none())
                .map(|meta| {
                    serde_json::json!({
                        "id": meta.key,
                        "channel": channel_from_session_key(&meta.key),
                        "started_at": meta.created_at.to_rfc3339(),
                        "last_activity": meta.last_activity.to_rfc3339(),
                        "status": session_status(now, meta.last_activity),
                        "message_count": meta.message_count,
                    })
                }),
        );
    }

    sessions.sort_by(|a, b| {
        let a_ts = a["last_activity"].as_str().unwrap_or_default();
        let b_ts = b["last_activity"].as_str().unwrap_or_default();
        b_ts.cmp(a_ts)
    });
    let archived_session_ids: Vec<String> = backend
        .list_archived_sessions()
        .into_iter()
        .filter_map(|meta| dashboard_session_id_from_key(&meta.key).map(ToOwned::to_owned))
        .collect();

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

/// GET /api/sessions/{id}/messages — load persisted dashboard chat transcript
pub async fn handle_api_session_messages(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<String>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let Some(ref backend) = state.session_backend else {
        return Json(serde_json::json!({
            "session_id": id,
            "messages": [],
            "session_persistence": false,
        }))
        .into_response();
    };

    let session_key = gateway_session_key(&id);
    let msgs = backend.load(&session_key);
    let messages: Vec<serde_json::Value> = msgs
        .into_iter()
        .map(|m| serde_json::json!({ "role": m.role, "content": m.content }))
        .collect();

    Json(serde_json::json!({
        "session_id": id,
        "messages": messages,
        "session_persistence": true,
    }))
    .into_response()
}

/// DELETE /api/sessions/{id} — archive a dashboard chat session
pub async fn handle_api_session_delete(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<String>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let Some(ref backend) = state.session_backend else {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Session persistence is disabled"})),
        )
            .into_response();
    };

    let session_key = gateway_session_key(&id);
    match backend.archive_session(&session_key) {
        Ok(Some(archived_at)) => Json(serde_json::json!({
            "deleted": true,
            "archived": true,
            "archived_at": archived_at.to_rfc3339(),
            "session_id": id,
        }))
        .into_response(),
        Ok(None) => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Session not found"})),
        )
            .into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to delete session: {e}")})),
        )
            .into_response(),
    }
}

/// PUT /api/sessions/{id} — rename a dashboard chat session
pub async fn handle_api_session_rename(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<String>,
    Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let Some(ref backend) = state.session_backend else {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Session persistence is disabled"})),
        )
            .into_response();
    };

    let name = body["name"].as_str().unwrap_or("").trim();
    if name.is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": "name is required"})),
        )
            .into_response();
    }

    let session_key = gateway_session_key(&id);

    // Verify the session exists before renaming
    let sessions = backend.list_sessions();
    if !sessions.contains(&session_key) {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Session not found"})),
        )
            .into_response();
    }

    match backend.set_session_name(&session_key, name) {
        Ok(()) => Json(serde_json::json!({"session_id": id, "name": name})).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to rename session: {e}")})),
        )
            .into_response(),
    }
}

/// GET /api/sessions/running — list sessions currently in "running" state
pub async fn handle_api_sessions_running(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let Some(ref backend) = state.session_backend else {
        return Json(serde_json::json!({
            "sessions": [],
            "message": "Session persistence is disabled"
        }))
        .into_response();
    };

    let running = backend.list_running_sessions();
    let sessions: Vec<serde_json::Value> = running
        .into_iter()
        .filter_map(|meta| {
            let session_id = dashboard_session_id_from_key(&meta.key)?;
            Some(serde_json::json!({
                "session_id": session_id,
                "created_at": meta.created_at.to_rfc3339(),
                "last_activity": meta.last_activity.to_rfc3339(),
                "message_count": meta.message_count,
            }))
        })
        .collect();

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

/// GET /api/sessions/{id}/state — get session state
pub async fn handle_api_session_state(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<String>,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let Some(ref backend) = state.session_backend else {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Session persistence is disabled"})),
        )
            .into_response();
    };

    let session_key = gateway_session_key(&id);
    match backend.get_session_state(&session_key) {
        Ok(Some(ss)) => {
            let mut resp = serde_json::json!({
                "session_id": id,
                "state": ss.state,
            });
            if let Some(turn_id) = ss.turn_id {
                resp["turn_id"] = serde_json::Value::String(turn_id);
            }
            if let Some(started) = ss.turn_started_at {
                resp["turn_started_at"] = serde_json::Value::String(started.to_rfc3339());
            }
            Json(resp).into_response()
        }
        Ok(None) => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "Session not found"})),
        )
            .into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": format!("Failed to get session state: {e}")})),
        )
            .into_response(),
    }
}

// ── Claude Code hook endpoint ────────────────────────────────────

/// POST /hooks/claude-code — receives HTTP hook events from Claude Code
/// sessions spawned by [`ClaudeCodeRunnerTool`].
///
/// Claude Code posts structured JSON describing tool executions, completions,
/// and errors. This handler logs the event and (when a Slack channel is
/// configured) could be wired to update a Slack message in-place.
pub async fn handle_claude_code_hook(
    State(state): State<AppState>,
    Json(payload): Json<crate::tools::claude_code_runner::ClaudeCodeHookEvent>,
) -> impl IntoResponse {
    // Do not require bearer-token auth: Claude Code subprocesses cannot easily
    // obtain a pairing token, and the hook carries a session_id that ties it
    // back to a session we spawned.
    let _ = &state; // retained for future Slack update wiring

    tracing::info!(
        session_id = %payload.session_id,
        event_type = %payload.event_type,
        tool_name = ?payload.tool_name,
        summary = ?payload.summary,
        "Claude Code hook event received"
    );

    Json(serde_json::json!({ "ok": true }))
}

// ── Channel events ingest ────────────────────────────────────────

/// POST /api/channel-events — accept a structured channel event from the
/// operator (or any internal service) and broadcast it to all connected
/// SSE / WebSocket subscribers via `event_tx`.
///
/// Also dispatches to configured external channels (Discord, etc.) if the
/// event payload includes a `channels` array naming them.
pub async fn handle_api_channel_events(
    State(state): State<AppState>,
    ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
    Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
    // Only allow from loopback — this is an internal operator endpoint.
    if !addr.ip().is_loopback() {
        return (
            StatusCode::FORBIDDEN,
            Json(
                serde_json::json!({ "error": "channel-events only accepts localhost connections" }),
            ),
        )
            .into_response();
    }

    // Wrap the incoming payload so subscribers can distinguish channel events
    // from observability events.  The operator already sends `type`, `agentId`,
    // `agentTitle`, `content`, and `timestamp` — we just tag the envelope.
    let event = serde_json::json!({
        "type": "channel_event",
        "payload": body,
        "timestamp": chrono::Utc::now().to_rfc3339(),
    });
    let _ = state.event_tx.send(event);

    // Register pending approval if this is an approval request
    if body.get("type").and_then(|v| v.as_str()) == Some("human_approval_request") {
        if let (Some(run_id), Some(step_id)) = (
            body.get("run_id").and_then(|v| v.as_str()),
            body.get("step_id").and_then(|v| v.as_str()),
        ) {
            let approve_keywords: Vec<String> = body
                .get("approve_keywords")
                .and_then(|v| v.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|v| v.as_str().map(String::from))
                        .collect()
                })
                .unwrap_or_default();
            let reject_keywords: Vec<String> = body
                .get("reject_keywords")
                .and_then(|v| v.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|v| v.as_str().map(String::from))
                        .collect()
                })
                .unwrap_or_default();
            let approval = super::approval_registry::PendingApproval::new(
                run_id.to_string(),
                step_id.to_string(),
                body.get("workflow_name")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string(),
                approve_keywords,
                reject_keywords,
                body.get("cwd")
                    .and_then(|v| v.as_str())
                    .unwrap_or("/tmp")
                    .to_string(),
            );
            state.approval_registry.register(approval);

            // Broadcast a distinct SSE event so dashboards can toast + badge
            // without having to unwrap the generic channel_event envelope.
            let message_text = body
                .pointer("/content/message")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let title_text = body
                .pointer("/content/title")
                .and_then(|v| v.as_str())
                .unwrap_or("Human approval needed");
            let _ = state.event_tx.send(serde_json::json!({
                "type": "human_approval_request",
                "run_id": run_id,
                "step_id": step_id,
                "workflow_name": body.get("workflow_name").and_then(|v| v.as_str()).unwrap_or(""),
                "title": title_text,
                "message": message_text,
                "timestamp": chrono::Utc::now().to_rfc3339(),
            }));
        }
    }

    // Dispatch to external channels (Discord, etc.) in the background.
    if let Some(channels) = body.get("channels").and_then(|c| c.as_array()) {
        let config = state.config.lock().clone();
        let title = body
            .pointer("/content/title")
            .and_then(|v| v.as_str())
            .unwrap_or("Notification")
            .to_string();
        let message = body
            .pointer("/content/message")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        // For human_approval_request events, append keyword instructions to the Discord message.
        let event_type = body.get("type").and_then(|v| v.as_str()).unwrap_or("");
        let keyword_instructions = if event_type == "human_approval_request" {
            let approve_kws: Vec<String> = body
                .get("approve_keywords")
                .and_then(|v| v.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|v| v.as_str().map(|s| format!("`{s}`")))
                        .collect()
                })
                .unwrap_or_default();
            let reject_kws: Vec<String> = body
                .get("reject_keywords")
                .and_then(|v| v.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|v| v.as_str().map(|s| format!("`{s}`")))
                        .collect()
                })
                .unwrap_or_default();
            let approve_list = if approve_kws.is_empty() {
                "`approve`, `yes`, `lgtm`".to_string()
            } else {
                approve_kws.join(", ")
            };
            let reject_list = if reject_kws.is_empty() {
                "`reject <your feedback>`".to_string()
            } else {
                format!("{} <your feedback>", reject_kws.join(", "))
            };
            format!(
                "\n---\nReply with one of: {approve_list} to approve.\nReply with {reject_list} to reject."
            )
        } else {
            String::new()
        };

        let is_approval_request = event_type == "human_approval_request";
        let approval_run_id = if is_approval_request {
            body.get("run_id")
                .and_then(|v| v.as_str())
                .map(String::from)
        } else {
            None
        };
        let approval_workflow_name = body
            .get("workflow_name")
            .and_then(|v| v.as_str())
            .unwrap_or("workflow")
            .to_string();

        for ch in channels.iter().filter_map(|v| v.as_str()) {
            // Case-insensitive channel routing — authored YAML often has
            // `Discord` / `DISCORD` and a silent fall-through is a painful
            // failure mode (the message just never shows up).
            match ch.to_ascii_lowercase().as_str() {
                "discord" => {
                    if let Some(dc) = &config.channels_config.discord {
                        if let Some(ch_id) = &dc.notification_channel_id {
                            let token = dc.bot_token.clone();
                            let channel_id = ch_id.clone();
                            let title_c = title.clone();
                            let message_c = format!("{message}{keyword_instructions}");
                            let approval_run_id = approval_run_id.clone();
                            let workflow_name = approval_workflow_name.clone();
                            let registry = state.approval_registry.clone();
                            tokio::spawn(async move {
                                let first_msg_id = dispatch_discord_long(
                                    &token,
                                    &channel_id,
                                    &title_c,
                                    &message_c,
                                )
                                .await;
                                // If this was an approval prompt, spin up a thread
                                // from the first message so replies are scoped to
                                // this specific approval, and attach the IDs to
                                // the registry so the Discord listener can match
                                // responses back to this run.
                                if let Some(run_id) = approval_run_id {
                                    let thread_name = format!(
                                        "Approval: {}",
                                        truncate_thread_name(&workflow_name)
                                    );
                                    let thread_id = match &first_msg_id {
                                        Some(msg_id) => {
                                            create_discord_thread(
                                                &token,
                                                &channel_id,
                                                msg_id,
                                                &thread_name,
                                            )
                                            .await
                                        }
                                        None => None,
                                    };
                                    registry.attach_discord(
                                        &run_id,
                                        Some(channel_id.clone()),
                                        thread_id,
                                        first_msg_id,
                                    );
                                }
                            });
                        } else {
                            tracing::debug!(
                                "channel-events: discord requested but notification_channel_id not configured"
                            );
                        }
                    }
                }
                "slack" => {
                    if let Some(sc) = &config.channels_config.slack {
                        if let Some(ch_id) = &sc.notification_channel_id {
                            let token = sc.bot_token.clone();
                            let channel_id = ch_id.clone();
                            let title_c = title.clone();
                            let message_c = format!("{message}{keyword_instructions}");
                            let approval_run_id = approval_run_id.clone();
                            let registry = state.approval_registry.clone();
                            tokio::spawn(async move {
                                let text = format!("*{title_c}*\n\n{message_c}");
                                let thread_ts =
                                    dispatch_slack_notification(&token, &channel_id, &text).await;
                                if let Some(run_id) = approval_run_id {
                                    registry.attach_slack(
                                        &run_id,
                                        Some(channel_id.clone()),
                                        thread_ts,
                                    );
                                }
                            });
                        } else {
                            tracing::debug!(
                                "channel-events: slack requested but notification_channel_id not configured"
                            );
                        }
                    }
                }
                "telegram" => {
                    if let Some(tc) = &config.channels_config.telegram {
                        if let Some(chat_id) = &tc.notification_chat_id {
                            let token = tc.bot_token.clone();
                            let chat_id_c = chat_id.clone();
                            let title_c = title.clone();
                            let message_c = format!("{message}{keyword_instructions}");
                            let approval_run_id = approval_run_id.clone();
                            let registry = state.approval_registry.clone();
                            tokio::spawn(async move {
                                let text = format!("*{title_c}*\n\n{message_c}");
                                let msg_id =
                                    dispatch_telegram_notification(&token, &chat_id_c, &text).await;
                                if let Some(run_id) = approval_run_id {
                                    registry.attach_telegram(
                                        &run_id,
                                        Some(chat_id_c.clone()),
                                        msg_id,
                                    );
                                }
                            });
                        } else {
                            tracing::debug!(
                                "channel-events: telegram requested but notification_chat_id not configured"
                            );
                        }
                    }
                }
                // "dashboard" is handled by the WebSocket broadcast above
                "dashboard" => {}
                _ => {
                    tracing::debug!("channel-events: unsupported channel '{ch}', skipping");
                }
            }
        }
    }

    Json(serde_json::json!({ "ok": true })).into_response()
}

/// Send a message to a Discord channel via the REST API. Returns the created
/// message's ID on success so callers can thread replies from it.
async fn dispatch_discord_notification(
    bot_token: &str,
    channel_id: &str,
    content: &str,
) -> Option<String> {
    let url = format!(
        "https://discord.com/api/v10/channels/{}/messages",
        channel_id
    );
    let client = reqwest::Client::new();
    match client
        .post(&url)
        .header("Authorization", format!("Bot {bot_token}"))
        .json(&serde_json::json!({ "content": content }))
        .send()
        .await
    {
        Ok(resp) if resp.status().is_success() => {
            tracing::info!("channel-events: Discord notification sent to {channel_id}");
            let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
            body.get("id").and_then(|v| v.as_str()).map(String::from)
        }
        Ok(resp) => {
            let status = resp.status();
            let body = resp.text().await.unwrap_or_default();
            tracing::warn!(
                "channel-events: Discord API {status}: {}",
                &body[..body.len().min(200)]
            );
            None
        }
        Err(e) => {
            tracing::warn!("channel-events: Discord send failed: {e}");
            None
        }
    }
}

/// Create a public thread from an existing message so approval replies are
/// scoped to that thread. Returns the thread ID (a Discord "channel" ID).
/// Returns None if the bot lacks "Create Public Threads" permission or the
/// call fails — in which case the dispatch layer falls back to matching by
/// reply-to-message.
async fn create_discord_thread(
    bot_token: &str,
    channel_id: &str,
    message_id: &str,
    name: &str,
) -> Option<String> {
    let url =
        format!("https://discord.com/api/v10/channels/{channel_id}/messages/{message_id}/threads");
    let client = reqwest::Client::new();
    match client
        .post(&url)
        .header("Authorization", format!("Bot {bot_token}"))
        .json(&serde_json::json!({
            "name": name,
            "auto_archive_duration": 1440, // 24h — matches Discord's default
        }))
        .send()
        .await
    {
        Ok(resp) if resp.status().is_success() => {
            let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
            let thread_id = body.get("id").and_then(|v| v.as_str()).map(String::from);
            if thread_id.is_some() {
                tracing::info!(
                    channel_id,
                    message_id,
                    "channel-events: Discord thread created for approval"
                );
            }
            thread_id
        }
        Ok(resp) => {
            let status = resp.status();
            let body = resp.text().await.unwrap_or_default();
            tracing::warn!(
                "channel-events: Discord thread create {status}: {} (falling back to reply-only scoping)",
                &body[..body.len().min(200)]
            );
            None
        }
        Err(e) => {
            tracing::warn!("channel-events: Discord thread create failed: {e}");
            None
        }
    }
}

/// Send a plain-text message to a Slack channel via the Web API. Returns the
/// posted message's `ts` so callers can scope replies via `thread_ts`.
async fn dispatch_slack_notification(
    bot_token: &str,
    channel_id: &str,
    text: &str,
) -> Option<String> {
    let client = reqwest::Client::new();
    match client
        .post("https://slack.com/api/chat.postMessage")
        .bearer_auth(bot_token)
        .json(&serde_json::json!({
            "channel": channel_id,
            "text": text,
        }))
        .send()
        .await
    {
        Ok(resp) => {
            let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
            if body.get("ok") == Some(&serde_json::Value::Bool(true)) {
                tracing::info!("channel-events: Slack notification sent to {channel_id}");
                body.get("ts").and_then(|v| v.as_str()).map(String::from)
            } else {
                let err = body
                    .get("error")
                    .and_then(|e| e.as_str())
                    .unwrap_or("unknown");
                tracing::warn!("channel-events: Slack chat.postMessage error: {err}");
                None
            }
        }
        Err(e) => {
            tracing::warn!("channel-events: Slack send failed: {e}");
            None
        }
    }
}

/// Send a plain-text message to a Telegram chat. Returns the sent message_id
/// so callers can scope approval replies via `reply_to_message`.
async fn dispatch_telegram_notification(bot_token: &str, chat_id: &str, text: &str) -> Option<i64> {
    let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
    let client = reqwest::Client::new();
    match client
        .post(&url)
        .json(&serde_json::json!({
            "chat_id": chat_id,
            "text": text,
        }))
        .send()
        .await
    {
        Ok(resp) => {
            let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
            if body.get("ok") == Some(&serde_json::Value::Bool(true)) {
                tracing::info!("channel-events: Telegram notification sent to {chat_id}");
                body.pointer("/result/message_id").and_then(|v| v.as_i64())
            } else {
                let desc = body
                    .get("description")
                    .and_then(|e| e.as_str())
                    .unwrap_or("unknown");
                tracing::warn!("channel-events: Telegram sendMessage error: {desc}");
                None
            }
        }
        Err(e) => {
            tracing::warn!("channel-events: Telegram send failed: {e}");
            None
        }
    }
}

/// Discord thread names must be ≤100 chars. Truncate with an ellipsis for
/// long workflow names.
fn truncate_thread_name(name: &str) -> String {
    const MAX: usize = 80; // leave room for the "Approval: " prefix
    if name.chars().count() <= MAX {
        name.to_string()
    } else {
        let truncated: String = name.chars().take(MAX - 1).collect();
        format!("{truncated}…")
    }
}

/// Send a long message to Discord, splitting into multiple ≤1900-char chunks
/// so nothing is truncated.  The first chunk gets the bold title header.
async fn dispatch_discord_long(
    bot_token: &str,
    channel_id: &str,
    title: &str,
    message: &str,
) -> Option<String> {
    let header = format!("**{}**\n\n", title);
    let full = format!("{}{}", header, message);

    if full.len() <= 1900 {
        return dispatch_discord_notification(bot_token, channel_id, &full).await;
    }

    // Split on line boundaries, respecting the 1900-char limit per message.
    let mut chunks: Vec<String> = Vec::new();
    let mut current = header;

    for line in message.lines() {
        // Hard-split individual lines that exceed the limit on their own.
        let sub_lines: Vec<&str> = if line.len() > 1850 {
            // Split at char boundaries near 1850 chars
            let mut parts = Vec::new();
            let mut start = 0;
            while start < line.len() {
                let end = std::cmp::min(start + 1850, line.len());
                // Try to break at a space if possible
                let break_at = if end < line.len() {
                    line[start..end]
                        .rfind(' ')
                        .map(|p| start + p + 1)
                        .unwrap_or(end)
                } else {
                    end
                };
                parts.push(&line[start..break_at]);
                start = break_at;
            }
            parts
        } else {
            vec![line]
        };

        for sub in sub_lines {
            let candidate = if current.is_empty() {
                sub.to_string()
            } else {
                format!("{}\n{}", current, sub)
            };

            if candidate.len() > 1900 && !current.is_empty() {
                chunks.push(current);
                current = sub.to_string();
            } else {
                current = candidate;
            }
        }
    }
    if !current.is_empty() {
        chunks.push(current);
    }

    let n = chunks.len();
    let mut first_msg_id: Option<String> = None;
    for (i, chunk) in chunks.iter().enumerate() {
        let text = if n > 1 {
            format!("{}\n`({}/{})`", chunk, i + 1, n)
        } else {
            chunk.clone()
        };
        let msg_id = dispatch_discord_notification(bot_token, channel_id, &text).await;
        if i == 0 {
            first_msg_id = msg_id;
        }
        // Rate limit: wait 600ms between messages to avoid Discord 429s
        if i + 1 < n {
            tokio::time::sleep(std::time::Duration::from_millis(600)).await;
        }
    }
    tracing::info!("channel-events: Discord long message sent in {n} chunk(s) to {channel_id}");
    first_msg_id
}

// ── Channels detail endpoint ─────────────────────────────────────

/// GET /api/channels — returns detailed channel information for the dashboard.
///
/// Iterates over configured channels and returns name, type, enabled status,
/// plus sensible defaults for runtime fields we don't yet track (message_count,
/// last_message_at, etc.).
pub async fn handle_api_channels(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(e) = require_auth(&state, &headers) {
        return e.into_response();
    }

    let config = state.config.lock().clone();
    let cc = &config.channels_config;

    let mut channels = Vec::new();

    // CLI is a plain bool, not Option-wrapped, so handle it separately.
    channels.push(serde_json::json!({
        "name": "cli",
        "type": "cli",
        "enabled": cc.cli,
        "status": if cc.cli { "active" } else { "inactive" },
        "message_count": 0,
        "last_message_at": null,
        "health": if cc.cli { "healthy" } else { "down" },
    }));

    // All other channels go through the ConfigHandle iterator.
    for (handle, present) in cc.channels() {
        channels.push(serde_json::json!({
            "name": handle.name(),
            "type": handle.name(),
            "enabled": present,
            "status": if present { "active" } else { "inactive" },
            "message_count": 0,
            "last_message_at": null,
            "health": if present { "healthy" } else { "down" },
        }));
    }

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::gateway::{AppState, GatewayRateLimiter, IdempotencyStore, nodes};
    use crate::memory::{Memory, MemoryCategory, MemoryEntry};
    use crate::providers::Provider;
    use crate::security::pairing::PairingGuard;
    use async_trait::async_trait;
    use axum::response::IntoResponse;
    use http_body_util::BodyExt;
    use parking_lot::Mutex;
    use std::sync::Arc;
    use std::time::Duration;

    struct MockMemory;

    #[async_trait]
    impl Memory for MockMemory {
        fn name(&self) -> &str {
            "mock"
        }

        async fn store(
            &self,
            _key: &str,
            _content: &str,
            _category: MemoryCategory,
            _session_id: Option<&str>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn recall(
            &self,
            _query: &str,
            _limit: usize,
            _session_id: Option<&str>,
            _since: Option<&str>,
            _until: Option<&str>,
        ) -> anyhow::Result<Vec<MemoryEntry>> {
            Ok(Vec::new())
        }

        async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
            Ok(None)
        }

        async fn list(
            &self,
            _category: Option<&MemoryCategory>,
            _session_id: Option<&str>,
        ) -> anyhow::Result<Vec<MemoryEntry>> {
            Ok(Vec::new())
        }

        async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
            Ok(false)
        }

        async fn count(&self) -> anyhow::Result<usize> {
            Ok(0)
        }

        async fn health_check(&self) -> bool {
            true
        }
    }

    struct MockProvider;

    #[async_trait]
    impl Provider for MockProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok("ok".to_string())
        }
    }

    fn test_state(config: crate::config::Config) -> AppState {
        AppState {
            config: Arc::new(Mutex::new(config)),
            provider: Arc::new(MockProvider),
            model: "test-model".into(),
            temperature: 0.0,
            mem: Arc::new(MockMemory),
            auto_save: false,
            webhook_secret_hash: None,
            pairing: Arc::new(PairingGuard::new(false, &[])),
            trust_forwarded_headers: false,
            rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
            auth_limiter: Arc::new(crate::gateway::auth_rate_limit::AuthRateLimiter::new()),
            idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
            whatsapp: None,
            whatsapp_app_secret: None,
            linq: None,
            linq_signing_secret: None,
            nextcloud_talk: None,
            nextcloud_talk_webhook_secret: None,
            wati: None,
            gmail_push: None,
            observer: Arc::new(crate::observability::NoopObserver),
            tools_registry: Arc::new(Vec::new()),
            cost_tracker: None,
            audit_logger: None,
            event_tx: tokio::sync::broadcast::channel(16).0,
            shutdown_tx: tokio::sync::watch::channel(false).0,
            node_registry: Arc::new(nodes::NodeRegistry::new(16)),
            session_backend: None,
            session_queue: Arc::new(crate::gateway::session_queue::SessionActorQueue::new(
                8, 30, 600,
            )),
            device_registry: None,
            pending_pairings: None,
            path_prefix: String::new(),
            canvas_store: crate::tools::canvas::CanvasStore::new(),
            mcp_registry: Arc::new(parking_lot::RwLock::new(None)),
            approval_registry: crate::gateway::approval_registry::global(),
            mcp_local_url: None,
            auth_profiles: None,
            service_token: Arc::<str>::from(""),
            #[cfg(feature = "webauthn")]
            webauthn: None,
        }
    }

    async fn response_json(response: axum::response::Response) -> serde_json::Value {
        let body = response
            .into_body()
            .collect()
            .await
            .expect("response body")
            .to_bytes();
        serde_json::from_slice(&body).expect("valid json response")
    }

    #[test]
    fn effective_gateway_mcp_config_injects_memory_env_for_operator() {
        let mut cfg = crate::config::Config::default();
        cfg.operator.enabled = true;
        cfg.gateway.host = "0.0.0.0".to_string();
        cfg.gateway.port = 4242;
        cfg.kumiho.memory_project = "CognitiveMemory".to_string();
        cfg.kumiho.memory_retrieval_limit = 3;
        cfg.memory.min_relevance_score = 0.7;

        let mcp = effective_gateway_mcp_config(&cfg);
        let operator = mcp
            .servers
            .iter()
            .find(|server| server.name == crate::agent::operator::OPERATOR_SERVER_NAME)
            .expect("operator mcp server should be injected");

        assert_eq!(
            operator.env.get("KUMIHO_MEMORY_RETRIEVAL_LIMIT"),
            Some(&"3".to_string())
        );
        assert_eq!(
            operator.env.get("REVKA_MEMORY_MIN_RELEVANCE_SCORE"),
            Some(&"0.7".to_string())
        );
        assert_eq!(
            operator.env.get("REVKA_GATEWAY_URL"),
            Some(&"http://127.0.0.1:4242".to_string())
        );
    }

    #[test]
    fn masking_keeps_toml_valid_and_preserves_api_keys_type() {
        let mut cfg = crate::config::Config::default();
        cfg.api_key = Some("sk-live-123".to_string());
        cfg.reliability.api_keys = vec!["rk-1".to_string(), "rk-2".to_string()];
        cfg.gateway.paired_tokens = vec!["pair-token-1".to_string()];
        cfg.tunnel.cloudflare = Some(crate::config::schema::CloudflareTunnelConfig {
            token: "cf-token".to_string(),
        });
        // qdrant.api_key removed (backend removed in Revka)
        cfg.channels_config.wati = Some(crate::config::schema::WatiConfig {
            api_token: "wati-token".to_string(),
            api_url: "https://live-mt-server.wati.io".to_string(),
            tenant_id: None,
            allowed_numbers: vec![],
            proxy_url: None,
        });
        cfg.channels_config.feishu = Some(crate::config::schema::FeishuConfig {
            app_id: "cli_aabbcc".to_string(),
            app_secret: "feishu-secret".to_string(),
            encrypt_key: Some("feishu-encrypt".to_string()),
            verification_token: Some("feishu-verify".to_string()),
            allowed_users: vec!["*".to_string()],
            receive_mode: crate::config::schema::LarkReceiveMode::Websocket,
            port: None,
            proxy_url: None,
        });
        cfg.channels_config.email = Some(crate::channels::email_channel::EmailConfig {
            imap_host: "imap.example.com".to_string(),
            imap_port: 993,
            imap_folder: "INBOX".to_string(),
            smtp_host: "smtp.example.com".to_string(),
            smtp_port: 465,
            smtp_tls: true,
            username: "agent@example.com".to_string(),
            password: "email-password-secret".to_string(),
            from_address: "agent@example.com".to_string(),
            idle_timeout_secs: 1740,
            allowed_senders: vec!["*".to_string()],
            default_subject: "Revka Message".to_string(),
        });
        cfg.model_routes = vec![crate::config::schema::ModelRouteConfig {
            hint: "reasoning".to_string(),
            provider: "openrouter".to_string(),
            model: "anthropic/claude-sonnet-4.6".to_string(),
            api_key: Some("route-model-key".to_string()),
        }];
        cfg.embedding_routes = vec![crate::config::schema::EmbeddingRouteConfig {
            hint: "semantic".to_string(),
            provider: "openai".to_string(),
            model: "text-embedding-3-small".to_string(),
            dimensions: Some(1536),
            api_key: Some("route-embed-key".to_string()),
        }];

        let masked = mask_sensitive_fields(&cfg);
        let toml = toml::to_string_pretty(&masked).expect("masked config should serialize");
        let parsed: crate::config::Config =
            toml::from_str(&toml).expect("masked config should remain valid TOML for Config");

        assert_eq!(parsed.api_key.as_deref(), Some(MASKED_SECRET));
        assert_eq!(
            parsed.reliability.api_keys,
            vec![MASKED_SECRET.to_string(), MASKED_SECRET.to_string()]
        );
        assert_eq!(
            parsed.gateway.paired_tokens,
            vec![MASKED_SECRET.to_string()]
        );
        assert_eq!(
            parsed.tunnel.cloudflare.as_ref().map(|v| v.token.as_str()),
            Some(MASKED_SECRET)
        );
        assert_eq!(
            parsed
                .channels_config
                .wati
                .as_ref()
                .map(|v| v.api_token.as_str()),
            Some(MASKED_SECRET)
        );
        // qdrant.api_key removed (backend removed in Revka)
        assert_eq!(
            parsed
                .channels_config
                .feishu
                .as_ref()
                .map(|v| v.app_secret.as_str()),
            Some(MASKED_SECRET)
        );
        assert_eq!(
            parsed
                .channels_config
                .feishu
                .as_ref()
                .and_then(|v| v.encrypt_key.as_deref()),
            Some(MASKED_SECRET)
        );
        assert_eq!(
            parsed
                .channels_config
                .feishu
                .as_ref()
                .and_then(|v| v.verification_token.as_deref()),
            Some(MASKED_SECRET)
        );
        assert_eq!(
            parsed
                .model_routes
                .first()
                .and_then(|v| v.api_key.as_deref()),
            Some(MASKED_SECRET)
        );
        assert_eq!(
            parsed
                .embedding_routes
                .first()
                .and_then(|v| v.api_key.as_deref()),
            Some(MASKED_SECRET)
        );
        assert_eq!(
            parsed
                .channels_config
                .email
                .as_ref()
                .map(|v| v.password.as_str()),
            Some(MASKED_SECRET)
        );
    }

    #[test]
    fn hydrate_config_for_save_restores_masked_secrets_and_paths() {
        let mut current = crate::config::Config::default();
        current.config_path = std::path::PathBuf::from("/tmp/current/config.toml");
        current.workspace_dir = std::path::PathBuf::from("/tmp/current/workspace");
        current.api_key = Some("real-key".to_string());
        current.reliability.api_keys = vec!["r1".to_string(), "r2".to_string()];
        current.gateway.paired_tokens = vec!["pair-1".to_string(), "pair-2".to_string()];
        current.tunnel.cloudflare = Some(crate::config::schema::CloudflareTunnelConfig {
            token: "cf-token-real".to_string(),
        });
        current.tunnel.ngrok = Some(crate::config::schema::NgrokTunnelConfig {
            auth_token: "ngrok-token-real".to_string(),
            domain: None,
        });
        // qdrant.api_key removed (backend removed in Revka)
        current.channels_config.wati = Some(crate::config::schema::WatiConfig {
            api_token: "wati-real".to_string(),
            api_url: "https://live-mt-server.wati.io".to_string(),
            tenant_id: None,
            allowed_numbers: vec![],
            proxy_url: None,
        });
        current.channels_config.feishu = Some(crate::config::schema::FeishuConfig {
            app_id: "cli_current".to_string(),
            app_secret: "feishu-secret-real".to_string(),
            encrypt_key: Some("feishu-encrypt-real".to_string()),
            verification_token: Some("feishu-verify-real".to_string()),
            allowed_users: vec!["*".to_string()],
            receive_mode: crate::config::schema::LarkReceiveMode::Websocket,
            port: None,
            proxy_url: None,
        });
        current.channels_config.email = Some(crate::channels::email_channel::EmailConfig {
            imap_host: "imap.example.com".to_string(),
            imap_port: 993,
            imap_folder: "INBOX".to_string(),
            smtp_host: "smtp.example.com".to_string(),
            smtp_port: 465,
            smtp_tls: true,
            username: "agent@example.com".to_string(),
            password: "email-password-real".to_string(),
            from_address: "agent@example.com".to_string(),
            idle_timeout_secs: 1740,
            allowed_senders: vec!["*".to_string()],
            default_subject: "Revka Message".to_string(),
        });
        current.model_routes = vec![
            crate::config::schema::ModelRouteConfig {
                hint: "reasoning".to_string(),
                provider: "openrouter".to_string(),
                model: "anthropic/claude-sonnet-4.6".to_string(),
                api_key: Some("route-model-key-1".to_string()),
            },
            crate::config::schema::ModelRouteConfig {
                hint: "fast".to_string(),
                provider: "openrouter".to_string(),
                model: "openai/gpt-4.1-mini".to_string(),
                api_key: Some("route-model-key-2".to_string()),
            },
        ];
        current.embedding_routes = vec![
            crate::config::schema::EmbeddingRouteConfig {
                hint: "semantic".to_string(),
                provider: "openai".to_string(),
                model: "text-embedding-3-small".to_string(),
                dimensions: Some(1536),
                api_key: Some("route-embed-key-1".to_string()),
            },
            crate::config::schema::EmbeddingRouteConfig {
                hint: "archive".to_string(),
                provider: "custom:https://emb.example.com/v1".to_string(),
                model: "bge-m3".to_string(),
                dimensions: Some(1024),
                api_key: Some("route-embed-key-2".to_string()),
            },
        ];

        let mut incoming = mask_sensitive_fields(&current);
        incoming.default_model = Some("gpt-4.1-mini".to_string());
        // Simulate UI changing only one key and keeping the first masked.
        incoming.reliability.api_keys = vec![MASKED_SECRET.to_string(), "r2-new".to_string()];
        incoming.gateway.paired_tokens = vec![MASKED_SECRET.to_string(), "pair-2-new".to_string()];
        if let Some(cloudflare) = incoming.tunnel.cloudflare.as_mut() {
            cloudflare.token = MASKED_SECRET.to_string();
        }
        if let Some(ngrok) = incoming.tunnel.ngrok.as_mut() {
            ngrok.auth_token = MASKED_SECRET.to_string();
        }
        // qdrant.api_key removed (backend removed in Revka)
        if let Some(wati) = incoming.channels_config.wati.as_mut() {
            wati.api_token = MASKED_SECRET.to_string();
        }
        if let Some(feishu) = incoming.channels_config.feishu.as_mut() {
            feishu.app_secret = MASKED_SECRET.to_string();
            feishu.encrypt_key = Some(MASKED_SECRET.to_string());
            feishu.verification_token = Some("feishu-verify-new".to_string());
        }
        if let Some(email) = incoming.channels_config.email.as_mut() {
            email.password = MASKED_SECRET.to_string();
        }
        incoming.model_routes[1].api_key = Some("route-model-key-2-new".to_string());
        incoming.embedding_routes[1].api_key = Some("route-embed-key-2-new".to_string());

        let hydrated = hydrate_config_for_save(incoming, &current);

        assert_eq!(hydrated.config_path, current.config_path);
        assert_eq!(hydrated.workspace_dir, current.workspace_dir);
        assert_eq!(hydrated.api_key, current.api_key);
        assert_eq!(hydrated.default_model.as_deref(), Some("gpt-4.1-mini"));
        assert_eq!(
            hydrated.reliability.api_keys,
            vec!["r1".to_string(), "r2-new".to_string()]
        );
        assert_eq!(
            hydrated.gateway.paired_tokens,
            vec!["pair-1".to_string(), "pair-2-new".to_string()]
        );
        assert_eq!(
            hydrated
                .tunnel
                .cloudflare
                .as_ref()
                .map(|v| v.token.as_str()),
            Some("cf-token-real")
        );
        assert_eq!(
            hydrated
                .tunnel
                .ngrok
                .as_ref()
                .map(|v| v.auth_token.as_str()),
            Some("ngrok-token-real")
        );
        // qdrant.api_key removed (backend removed in Revka)
        assert_eq!(
            hydrated
                .channels_config
                .wati
                .as_ref()
                .map(|v| v.api_token.as_str()),
            Some("wati-real")
        );
        assert_eq!(
            hydrated
                .channels_config
                .feishu
                .as_ref()
                .map(|v| v.app_secret.as_str()),
            Some("feishu-secret-real")
        );
        assert_eq!(
            hydrated
                .channels_config
                .feishu
                .as_ref()
                .and_then(|v| v.encrypt_key.as_deref()),
            Some("feishu-encrypt-real")
        );
        assert_eq!(
            hydrated
                .channels_config
                .feishu
                .as_ref()
                .and_then(|v| v.verification_token.as_deref()),
            Some("feishu-verify-new")
        );
        assert_eq!(
            hydrated.model_routes[0].api_key.as_deref(),
            Some("route-model-key-1")
        );
        assert_eq!(
            hydrated.model_routes[1].api_key.as_deref(),
            Some("route-model-key-2-new")
        );
        assert_eq!(
            hydrated.embedding_routes[0].api_key.as_deref(),
            Some("route-embed-key-1")
        );
        assert_eq!(
            hydrated.embedding_routes[1].api_key.as_deref(),
            Some("route-embed-key-2-new")
        );
        assert_eq!(
            hydrated
                .channels_config
                .email
                .as_ref()
                .map(|v| v.password.as_str()),
            Some("email-password-real")
        );
    }

    #[test]
    fn hydrate_config_for_save_restores_route_keys_by_identity_and_clears_unmatched_masks() {
        let mut current = crate::config::Config::default();
        current.model_routes = vec![
            crate::config::schema::ModelRouteConfig {
                hint: "reasoning".to_string(),
                provider: "openrouter".to_string(),
                model: "anthropic/claude-sonnet-4.6".to_string(),
                api_key: Some("route-model-key-1".to_string()),
            },
            crate::config::schema::ModelRouteConfig {
                hint: "fast".to_string(),
                provider: "openrouter".to_string(),
                model: "openai/gpt-4.1-mini".to_string(),
                api_key: Some("route-model-key-2".to_string()),
            },
        ];
        current.embedding_routes = vec![
            crate::config::schema::EmbeddingRouteConfig {
                hint: "semantic".to_string(),
                provider: "openai".to_string(),
                model: "text-embedding-3-small".to_string(),
                dimensions: Some(1536),
                api_key: Some("route-embed-key-1".to_string()),
            },
            crate::config::schema::EmbeddingRouteConfig {
                hint: "archive".to_string(),
                provider: "custom:https://emb.example.com/v1".to_string(),
                model: "bge-m3".to_string(),
                dimensions: Some(1024),
                api_key: Some("route-embed-key-2".to_string()),
            },
        ];

        let mut incoming = mask_sensitive_fields(&current);
        incoming.model_routes.swap(0, 1);
        incoming.embedding_routes.swap(0, 1);
        incoming
            .model_routes
            .push(crate::config::schema::ModelRouteConfig {
                hint: "new".to_string(),
                provider: "openai".to_string(),
                model: "gpt-4.1".to_string(),
                api_key: Some(MASKED_SECRET.to_string()),
            });
        incoming
            .embedding_routes
            .push(crate::config::schema::EmbeddingRouteConfig {
                hint: "new-embed".to_string(),
                provider: "custom:https://emb2.example.com/v1".to_string(),
                model: "bge-small".to_string(),
                dimensions: Some(768),
                api_key: Some(MASKED_SECRET.to_string()),
            });

        let hydrated = hydrate_config_for_save(incoming, &current);

        assert_eq!(
            hydrated.model_routes[0].api_key.as_deref(),
            Some("route-model-key-2")
        );
        assert_eq!(
            hydrated.model_routes[1].api_key.as_deref(),
            Some("route-model-key-1")
        );
        assert_eq!(hydrated.model_routes[2].api_key, None);
        assert_eq!(
            hydrated.embedding_routes[0].api_key.as_deref(),
            Some("route-embed-key-2")
        );
        assert_eq!(
            hydrated.embedding_routes[1].api_key.as_deref(),
            Some("route-embed-key-1")
        );
        assert_eq!(hydrated.embedding_routes[2].api_key, None);
        assert!(
            hydrated
                .model_routes
                .iter()
                .all(|route| route.api_key.as_deref() != Some(MASKED_SECRET))
        );
        assert!(
            hydrated
                .embedding_routes
                .iter()
                .all(|route| route.api_key.as_deref() != Some(MASKED_SECRET))
        );
    }

    #[tokio::test]
    async fn cron_api_shell_roundtrip_includes_delivery() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config = crate::config::Config {
            workspace_dir: tmp.path().join("workspace"),
            config_path: tmp.path().join("config.toml"),
            ..crate::config::Config::default()
        };
        std::fs::create_dir_all(&config.workspace_dir).unwrap();
        let state = test_state(config);

        let add_response = handle_api_cron_add(
            State(state.clone()),
            HeaderMap::new(),
            Json(
                serde_json::from_value::<CronAddBody>(serde_json::json!({
                    "name": "test-job",
                    "schedule": "*/5 * * * *",
                    "command": "echo hello",
                    "delivery": {
                        "mode": "announce",
                        "channel": "discord",
                        "to": "1234567890",
                        "best_effort": true
                    }
                }))
                .expect("body should deserialize"),
            ),
        )
        .await
        .into_response();

        let add_json = response_json(add_response).await;
        assert_eq!(add_json["status"], "ok");
        assert_eq!(add_json["job"]["delivery"]["mode"], "announce");
        assert_eq!(add_json["job"]["delivery"]["channel"], "discord");
        assert_eq!(add_json["job"]["delivery"]["to"], "1234567890");

        let list_response = handle_api_cron_list(State(state), HeaderMap::new())
            .await
            .into_response();
        let list_json = response_json(list_response).await;
        let jobs = list_json["jobs"].as_array().expect("jobs array");
        assert_eq!(jobs.len(), 1);
        assert_eq!(jobs[0]["delivery"]["mode"], "announce");
        assert_eq!(jobs[0]["delivery"]["channel"], "discord");
        assert_eq!(jobs[0]["delivery"]["to"], "1234567890");
    }

    #[tokio::test]
    async fn cron_api_accepts_agent_jobs() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config = crate::config::Config {
            workspace_dir: tmp.path().join("workspace"),
            config_path: tmp.path().join("config.toml"),
            ..crate::config::Config::default()
        };
        std::fs::create_dir_all(&config.workspace_dir).unwrap();
        let state = test_state(config);

        let response = handle_api_cron_add(
            State(state.clone()),
            HeaderMap::new(),
            Json(
                serde_json::from_value::<CronAddBody>(serde_json::json!({
                    "name": "agent-job",
                    "schedule": "*/5 * * * *",
                    "job_type": "agent",
                    "command": "ignored shell command",
                    "prompt": "summarize the latest logs"
                }))
                .expect("body should deserialize"),
            ),
        )
        .await
        .into_response();

        let json = response_json(response).await;
        assert_eq!(json["status"], "ok");

        let config = state.config.lock().clone();
        let jobs = crate::cron::list_jobs(&config).unwrap();
        assert_eq!(jobs.len(), 1);
        assert_eq!(jobs[0].job_type, crate::cron::JobType::Agent);
        assert_eq!(jobs[0].prompt.as_deref(), Some("summarize the latest logs"));
    }

    #[tokio::test]
    async fn cron_api_rejects_announce_delivery_without_target() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config = crate::config::Config {
            workspace_dir: tmp.path().join("workspace"),
            config_path: tmp.path().join("config.toml"),
            ..crate::config::Config::default()
        };
        std::fs::create_dir_all(&config.workspace_dir).unwrap();
        let state = test_state(config);

        let response = handle_api_cron_add(
            State(state.clone()),
            HeaderMap::new(),
            Json(
                serde_json::from_value::<CronAddBody>(serde_json::json!({
                    "name": "invalid-delivery-job",
                    "schedule": "*/5 * * * *",
                    "command": "echo hello",
                    "delivery": {
                        "mode": "announce",
                        "channel": "discord"
                    }
                }))
                .expect("body should deserialize"),
            ),
        )
        .await
        .into_response();

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        let json = response_json(response).await;
        assert!(
            json["error"]
                .as_str()
                .unwrap_or_default()
                .contains("delivery.to is required")
        );

        let config = state.config.lock().clone();
        assert!(crate::cron::list_jobs(&config).unwrap().is_empty());
    }

    #[tokio::test]
    async fn cron_api_rejects_announce_delivery_with_unsupported_channel() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config = crate::config::Config {
            workspace_dir: tmp.path().join("workspace"),
            config_path: tmp.path().join("config.toml"),
            ..crate::config::Config::default()
        };
        std::fs::create_dir_all(&config.workspace_dir).unwrap();
        let state = test_state(config);

        let response = handle_api_cron_add(
            State(state.clone()),
            HeaderMap::new(),
            Json(
                serde_json::from_value::<CronAddBody>(serde_json::json!({
                    "name": "invalid-delivery-job",
                    "schedule": "*/5 * * * *",
                    "command": "echo hello",
                    "delivery": {
                        "mode": "announce",
                        "channel": "email",
                        "to": "alerts@example.com"
                    }
                }))
                .expect("body should deserialize"),
            ),
        )
        .await
        .into_response();

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        let json = response_json(response).await;
        assert!(
            json["error"]
                .as_str()
                .unwrap_or_default()
                .contains("unsupported delivery channel")
        );

        let config = state.config.lock().clone();
        assert!(crate::cron::list_jobs(&config).unwrap().is_empty());
    }

    // ── require_auth: bearer + service-token credentials ─────────────────

    /// State configured to require pairing, with one accepted bearer token
    /// (`PAIR-TOKEN`) and one service token (`SERVICE-TOKEN`).
    fn auth_test_state() -> AppState {
        let cfg = crate::config::Config::default();
        let mut state = test_state(cfg);
        state.pairing = Arc::new(PairingGuard::new(true, &["PAIR-TOKEN".to_string()]));
        state.service_token = Arc::<str>::from("SERVICE-TOKEN");
        state
    }

    #[test]
    fn require_auth_accepts_valid_bearer_token() {
        let state = auth_test_state();
        let mut headers = HeaderMap::new();
        headers.insert(header::AUTHORIZATION, "Bearer PAIR-TOKEN".parse().unwrap());
        assert!(require_auth(&state, &headers).is_ok());
    }

    #[test]
    fn require_auth_accepts_valid_service_token() {
        let state = auth_test_state();
        let mut headers = HeaderMap::new();
        headers.insert(SERVICE_TOKEN_HEADER, "SERVICE-TOKEN".parse().unwrap());
        assert!(require_auth(&state, &headers).is_ok());
    }

    #[test]
    fn supervisor_detected_requires_launchd_or_systemd_marker() {
        assert!(supervisor_detected(true, false)); // launchd: XPC_SERVICE_NAME
        assert!(supervisor_detected(false, true)); // systemd: INVOCATION_ID
        assert!(supervisor_detected(true, true));
        assert!(!supervisor_detected(false, false)); // foreground `revka daemon`
    }

    #[tokio::test]
    async fn cost_usage_ingest_requires_service_token_and_records_metadata() {
        let tmp = tempfile::TempDir::new().unwrap();
        let mut config = crate::config::Config {
            workspace_dir: tmp.path().join("workspace"),
            config_path: tmp.path().join("config.toml"),
            ..crate::config::Config::default()
        };
        config.cost.enabled = true;
        config.cost.prices = std::collections::HashMap::from([(
            "openai-codex/gpt-5".to_string(),
            crate::config::schema::ModelPricing {
                input: 1.0,
                output: 2.0,
            },
        )]);
        std::fs::create_dir_all(&config.workspace_dir).unwrap();
        let tracker = Arc::new(
            crate::cost::CostTracker::new(config.cost.clone(), &config.workspace_dir).unwrap(),
        );
        let mut state = test_state(config);
        state.cost_tracker = Some(Arc::clone(&tracker));
        state.service_token = Arc::<str>::from("SERVICE-TOKEN");

        let response = handle_api_cost_usage(
            State(state),
            {
                let mut headers = HeaderMap::new();
                headers.insert(SERVICE_TOKEN_HEADER, "SERVICE-TOKEN".parse().unwrap());
                headers
            },
            Json(CostUsageIngestRequest {
                model: "gpt-5.5".to_string(),
                provider: Some("openai-codex".to_string()),
                input_tokens: Some(1000),
                output_tokens: Some(500),
                source: Some("sidecar".to_string()),
                agent_id: Some("agent-1".to_string()),
                agent_title: Some("Worker".to_string()),
            }),
        )
        .await
        .into_response();

        assert_eq!(response.status(), StatusCode::OK);
        let json = response_json(response).await;
        assert_eq!(json["recorded"], true);
        assert!(json["usage"]["cost_usd"].as_f64().unwrap_or_default() > 0.0);

        let summary = tracker.get_summary().unwrap();
        assert_eq!(summary.request_count, 1);
        assert_eq!(summary.by_source["sidecar"].request_count, 1);
        assert_eq!(
            summary.by_agent["agent-1"].agent_title.as_deref(),
            Some("Worker")
        );
    }

    #[test]
    fn require_auth_rejects_when_no_credentials() {
        let state = auth_test_state();
        let headers = HeaderMap::new();
        let err = require_auth(&state, &headers).expect_err("missing creds should fail");
        assert_eq!(err.0, StatusCode::UNAUTHORIZED);
    }

    #[test]
    fn dashboard_session_id_from_key_only_accepts_gateway_storage_prefix() {
        assert_eq!(
            dashboard_session_id_from_key("gw_operator-main"),
            Some("operator-main")
        );
        assert_eq!(
            dashboard_session_id_from_key("gw_8d43b6ef-0f18-4c3f-b04c-3a03f79e2c72"),
            Some("8d43b6ef-0f18-4c3f-b04c-3a03f79e2c72")
        );
        assert_eq!(dashboard_session_id_from_key("discord_123_456"), None);
        assert_eq!(dashboard_session_id_from_key("telegram_chat_alice"), None);
    }

    #[test]
    fn gateway_session_key_is_private_dashboard_storage_key() {
        assert_eq!(gateway_session_key("operator-main"), "gw_operator-main");
    }

    #[test]
    fn channel_from_session_key_preserves_connected_channel_source() {
        assert_eq!(channel_from_session_key("discord_123_456"), "discord");
        assert_eq!(channel_from_session_key("telegram_chat_alice"), "telegram");
        assert_eq!(
            channel_from_session_key("nextcloud_talk_room_alice"),
            "nextcloud_talk"
        );
        assert_eq!(channel_from_session_key("custom_surface_user"), "custom");
    }

    #[test]
    fn require_auth_rejects_wrong_bearer_token() {
        let state = auth_test_state();
        let mut headers = HeaderMap::new();
        headers.insert(header::AUTHORIZATION, "Bearer WRONG".parse().unwrap());
        let err = require_auth(&state, &headers).expect_err("wrong bearer should fail");
        assert_eq!(err.0, StatusCode::UNAUTHORIZED);
    }

    #[test]
    fn require_auth_rejects_wrong_service_token() {
        let state = auth_test_state();
        let mut headers = HeaderMap::new();
        headers.insert(SERVICE_TOKEN_HEADER, "WRONG-SERVICE".parse().unwrap());
        let err = require_auth(&state, &headers).expect_err("wrong service token should fail");
        assert_eq!(err.0, StatusCode::UNAUTHORIZED);
    }

    #[test]
    fn require_auth_does_not_accept_empty_service_token_when_unconfigured() {
        // Even if the gateway has no service token configured, an empty
        // `X-Revka-Service-Token: ` header must not authenticate.
        let cfg = crate::config::Config::default();
        let mut state = test_state(cfg);
        state.pairing = Arc::new(PairingGuard::new(true, &["PAIR-TOKEN".to_string()]));
        state.service_token = Arc::<str>::from("");
        let mut headers = HeaderMap::new();
        headers.insert(SERVICE_TOKEN_HEADER, "".parse().unwrap());
        let err = require_auth(&state, &headers).expect_err("empty config must reject");
        assert_eq!(err.0, StatusCode::UNAUTHORIZED);
    }
}
