use super::GatewayState;
use crate::config::{Config, ConfigDiagnostic, cleanup_invalid_config, source_revision};
use crate::memory::MemoryCategory;
use axum::Json;
use axum::body::Body;
use axum::extract::{Multipart, Path, Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio_util::io::ReaderStream;

const REDACTED: &str = "********";
const MAX_CONFIG_BYTES: usize = 1024 * 1024;
const MAX_PROFILE_BYTES: usize = 256 * 1024;

struct TemporaryUpload {
    path: PathBuf,
    committed: bool,
}

impl Drop for TemporaryUpload {
    fn drop(&mut self) {
        if !self.committed {
            let _ = std::fs::remove_file(&self.path);
        }
    }
}

#[derive(Serialize)]
pub struct HealthResponse {
    status: String,
    version: String,
}

pub async fn health() -> Json<HealthResponse> {
    Json(HealthResponse {
        status: "ok".to_string(),
        version: env!("CARGO_PKG_VERSION").to_string(),
    })
}

pub async fn health_report(
    State(state): State<Arc<GatewayState>>,
) -> Json<crate::health::HealthReport> {
    Json(state.health.check().await)
}

fn static_response(content_type: &'static str, content: &'static str) -> Response {
    Response::builder()
        .header(header::CONTENT_TYPE, content_type)
        .header(header::CACHE_CONTROL, "no-cache")
        .header("X-Content-Type-Options", "nosniff")
        .header(
            "Content-Security-Policy",
            "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'",
        )
        .body(Body::from(content))
        .expect("valid static response")
}

pub async fn webui_index() -> Response {
    static_response(
        "text/html; charset=utf-8",
        include_str!(concat!(env!("OUT_DIR"), "/webui/index.html")),
    )
}

pub async fn webui_script() -> Response {
    static_response(
        "text/javascript; charset=utf-8",
        include_str!(concat!(env!("OUT_DIR"), "/webui/app.js")),
    )
}

pub async fn webui_styles() -> Response {
    static_response(
        "text/css; charset=utf-8",
        include_str!(concat!(env!("OUT_DIR"), "/webui/styles.css")),
    )
}

pub async fn webui_theme_init() -> Response {
    static_response(
        "text/javascript; charset=utf-8",
        include_str!(concat!(env!("OUT_DIR"), "/webui/theme-init.js")),
    )
}

const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
    (
        "space-grotesk.woff2",
        include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk.woff2")),
    ),
    (
        "jetbrains-mono-400.woff2",
        include_bytes!(concat!(
            env!("OUT_DIR"),
            "/webui/fonts/jetbrains-mono-400.woff2"
        )),
    ),
    (
        "jetbrains-mono-700.woff2",
        include_bytes!(concat!(
            env!("OUT_DIR"),
            "/webui/fonts/jetbrains-mono-700.woff2"
        )),
    ),
];

pub async fn webui_font(Path(name): Path<String>) -> Response {
    let bytes = EMBEDDED_FONTS
        .iter()
        .find(|(font_name, _)| *font_name == name)
        .map(|(_, bytes)| *bytes);
    let Some(bytes) = bytes else {
        return StatusCode::NOT_FOUND.into_response();
    };
    Response::builder()
        .header(header::CONTENT_TYPE, "font/woff2")
        .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
        .header("X-Content-Type-Options", "nosniff")
        .body(Body::from(bytes))
        .expect("valid font response")
}

#[derive(Debug)]
pub struct ApiError {
    status: StatusCode,
    message: String,
}

impl ApiError {
    fn bad_request(message: impl Into<String>) -> Self {
        Self {
            status: StatusCode::BAD_REQUEST,
            message: message.into(),
        }
    }

    fn not_found(message: impl Into<String>) -> Self {
        Self {
            status: StatusCode::NOT_FOUND,
            message: message.into(),
        }
    }

    fn payload_too_large(message: impl Into<String>) -> Self {
        Self {
            status: StatusCode::PAYLOAD_TOO_LARGE,
            message: message.into(),
        }
    }

    fn conflict(message: impl Into<String>) -> Self {
        Self {
            status: StatusCode::CONFLICT,
            message: message.into(),
        }
    }

    fn service_unavailable(message: impl Into<String>) -> Self {
        Self {
            status: StatusCode::SERVICE_UNAVAILABLE,
            message: message.into(),
        }
    }

    fn internal(error: impl std::fmt::Display) -> Self {
        tracing::error!(error = %error, "WebUI API request failed");
        Self {
            status: StatusCode::INTERNAL_SERVER_ERROR,
            message: error.to_string(),
        }
    }

    fn internal_with_message(error: impl std::fmt::Display, message: impl Into<String>) -> Self {
        tracing::error!(error = %error, "WebUI API request failed");
        Self {
            status: StatusCode::INTERNAL_SERVER_ERROR,
            message: message.into(),
        }
    }
}

pub async fn upload_file(
    State(state): State<Arc<GatewayState>>,
    Path(client_id): Path<String>,
    mut multipart: Multipart,
) -> Result<(StatusCode, Json<crate::protocol::UploadDescriptor>), ApiError> {
    if !valid_client_id(&client_id) {
        return Err(ApiError::bad_request("invalid client id"));
    }
    if !state.uploads.enabled() {
        return Err(ApiError::bad_request("file transfer is disabled"));
    }

    let mut field = multipart
        .next_field()
        .await
        .map_err(|error| ApiError::bad_request(format!("invalid multipart body: {error}")))?
        .ok_or_else(|| ApiError::bad_request("file field is required"))?;
    if field.name() != Some("file") {
        return Err(ApiError::bad_request("expected multipart field named file"));
    }
    let original_name = field.file_name().unwrap_or("attachment").to_string();
    let safe_name = crate::gateway::uploads::UploadRegistry::safe_file_name(&original_name);
    let (temporary_path, final_path, upload_id) = state
        .uploads
        .allocate_path(&client_id, &safe_name)
        .await
        .map_err(ApiError::internal)?;
    let mut temporary = TemporaryUpload {
        path: temporary_path.clone(),
        committed: false,
    };
    let mut output = tokio::fs::File::create(&temporary_path)
        .await
        .map_err(ApiError::internal)?;
    let mut size = 0_u64;
    while let Some(chunk) = field
        .chunk()
        .await
        .map_err(|error| ApiError::bad_request(format!("failed to read upload: {error}")))?
    {
        size = size.saturating_add(chunk.len() as u64);
        if size > state.uploads.max_file_bytes() {
            drop(output);
            let _ = tokio::fs::remove_file(&temporary_path).await;
            return Err(ApiError::payload_too_large(format!(
                "file exceeds {} bytes",
                state.uploads.max_file_bytes()
            )));
        }
        if let Err(error) = output.write_all(&chunk).await {
            drop(output);
            let _ = tokio::fs::remove_file(&temporary_path).await;
            return Err(ApiError::internal(error));
        }
    }
    if size == 0 {
        drop(output);
        let _ = tokio::fs::remove_file(&temporary_path).await;
        return Err(ApiError::bad_request("empty files are not supported"));
    }
    output.sync_all().await.map_err(ApiError::internal)?;
    drop(output);
    tokio::fs::rename(&temporary_path, &final_path)
        .await
        .map_err(ApiError::internal)?;
    temporary.committed = true;

    let mime_type = mime_guess::from_path(&safe_name)
        .first_or_octet_stream()
        .to_string();
    let media_type = media_type_for_mime(&mime_type).to_string();
    let descriptor = match state
        .uploads
        .register(
            upload_id,
            client_id,
            final_path.clone(),
            crate::protocol::UploadDescriptor {
                upload_id: String::new(),
                name: safe_name,
                media_type,
                mime_type,
                size,
                expires_at: 0,
            },
        )
        .await
    {
        Ok(descriptor) => descriptor,
        Err(error) => {
            let _ = tokio::fs::remove_file(final_path).await;
            return Err(ApiError::bad_request(error.to_string()));
        }
    };
    Ok((StatusCode::CREATED, Json(descriptor)))
}

#[derive(Debug, Default, Deserialize)]
pub struct AttachmentQuery {
    disposition: Option<String>,
}

pub async fn download_attachment(
    State(state): State<Arc<GatewayState>>,
    Path((client_id, session_id, message_id, index)): Path<(String, String, String, usize)>,
    Query(query): Query<AttachmentQuery>,
) -> Result<Response, ApiError> {
    if !valid_client_id(&client_id) {
        return Err(ApiError::not_found("attachment not found"));
    }
    let unified = crate::session::UnifiedSessionId::parse(&session_id)
        .filter(|id| id.channel == "cli_chat" && id.chat_id == client_id)
        .ok_or_else(|| ApiError::not_found("attachment not found"))?;
    let message = state
        .storage
        .get_message(&unified.to_string(), &message_id)
        .await
        .map_err(ApiError::internal)?
        .ok_or_else(|| ApiError::not_found("attachment not found"))?;
    let media_refs = message
        .media_refs
        .as_deref()
        .and_then(|value| serde_json::from_str::<Vec<crate::bus::MediaRef>>(value).ok())
        .unwrap_or_default();
    let media_ref = media_refs
        .get(index)
        .ok_or_else(|| ApiError::not_found("attachment not found"))?;
    let path = FsPath::new(&media_ref.path);
    let metadata = tokio::fs::metadata(path)
        .await
        .map_err(|_| ApiError::not_found("the original file was moved or deleted"))?;
    if !metadata.is_file() {
        return Err(ApiError::not_found(
            "the original file was moved or deleted",
        ));
    }
    let file = tokio::fs::File::open(path)
        .await
        .map_err(|_| ApiError::not_found("the original file was moved or deleted"))?;
    let name = path
        .file_name()
        .and_then(|value| value.to_str())
        .map(crate::gateway::uploads::UploadRegistry::safe_file_name)
        .unwrap_or_else(|| "attachment".to_string());
    let mime_type = mime_guess::from_path(&name)
        .first_or_octet_stream()
        .to_string();
    let inline = query.disposition.as_deref() == Some("inline") && inline_mime_allowed(&mime_type);
    let disposition = if inline { "inline" } else { "attachment" };
    let encoded_name = encode_header_filename(&name);

    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, mime_type)
        .header(header::CONTENT_LENGTH, metadata.len())
        .header(
            header::CONTENT_DISPOSITION,
            format!("{disposition}; filename*=UTF-8''{encoded_name}"),
        )
        .header("X-Content-Type-Options", "nosniff")
        .header(header::CACHE_CONTROL, "private, no-store")
        .body(Body::from_stream(ReaderStream::new(file)))
        .map_err(ApiError::internal)
}

fn valid_client_id(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 64
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
}

fn media_type_for_mime(mime: &str) -> &'static str {
    if mime.starts_with("image/") {
        "image"
    } else if mime.starts_with("audio/") {
        "audio"
    } else if mime.starts_with("video/") {
        "video"
    } else {
        "file"
    }
}

fn inline_mime_allowed(mime: &str) -> bool {
    matches!(
        mime,
        "image/png"
            | "image/jpeg"
            | "image/gif"
            | "image/webp"
            | "image/bmp"
            | "image/avif"
            | "image/x-icon"
            | "audio/mpeg"
            | "audio/ogg"
            | "audio/wav"
            | "video/mp4"
            | "video/webm"
    )
}

fn encode_header_filename(value: &str) -> String {
    value
        .as_bytes()
        .iter()
        .map(|byte| match *byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
                (*byte as char).to_string()
            }
            other => format!("%{other:02X}"),
        })
        .collect()
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (self.status, Json(json!({ "error": self.message }))).into_response()
    }
}

#[derive(Serialize)]
pub struct ConfigResponse {
    config: Value,
    path: String,
    revision: String,
    diagnostics: Vec<ConfigDiagnostic>,
    restart_required: bool,
}

#[derive(Deserialize)]
pub struct CleanupInvalidConfigRequest {
    revision: String,
}

#[derive(Serialize)]
pub struct ReloadResponse {
    generation: u64,
    message: String,
}

pub async fn reload_config(
    State(state): State<Arc<GatewayState>>,
) -> Result<Json<ReloadResponse>, ApiError> {
    let accepted = state.reload.request().await.map_err(|error| match error {
        super::reload::ReloadError::AlreadyPending => ApiError::conflict(error.to_string()),
        super::reload::ReloadError::ShuttingDown
        | super::reload::ReloadError::PreparationFailed(_) => {
            ApiError::service_unavailable(error.to_string())
        }
        super::reload::ReloadError::InvalidConfig(_)
        | super::reload::ReloadError::ImmutableField(_) => ApiError::bad_request(error.to_string()),
    })?;
    Ok(Json(ReloadResponse {
        generation: accepted.generation,
        message: accepted.message,
    }))
}

pub async fn reload_status(
    State(state): State<Arc<GatewayState>>,
) -> Json<super::reload::ReloadStatus> {
    Json(state.reload.status())
}

pub async fn get_config(
    State(state): State<Arc<GatewayState>>,
) -> Result<Json<ConfigResponse>, ApiError> {
    let _write_guard = state.config_write_lock.lock().await;
    let raw = tokio::fs::read_to_string(&state.config_path)
        .await
        .map_err(ApiError::internal)?;
    let revision = source_revision(&raw);
    let path = state.config_path.clone();
    let context = state.config_load_context.clone();
    let inspected = tokio::task::spawn_blocking(move || {
        Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
            .map_err(|error| error.to_string())
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad_request)?;
    if inspected.source_revision != revision {
        return Err(ApiError::conflict(
            "config.json changed while it was being inspected; retry",
        ));
    }
    let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
    redact_secrets(&mut value);
    Ok(Json(ConfigResponse {
        config: value,
        path: state.config_path.display().to_string(),
        revision,
        diagnostics: inspected.diagnostics,
        restart_required: false,
    }))
}

pub async fn put_config(
    State(state): State<Arc<GatewayState>>,
    Json(mut incoming): Json<Value>,
) -> Result<Json<ConfigResponse>, ApiError> {
    let _write_guard = state.config_write_lock.lock().await;
    if incoming.get("config").is_some() {
        incoming = incoming
            .get_mut("config")
            .map(Value::take)
            .ok_or_else(|| ApiError::bad_request("config is required"))?;
    }
    let encoded_size = serde_json::to_vec(&incoming)
        .map_err(|error| ApiError::bad_request(error.to_string()))?
        .len();
    if encoded_size > MAX_CONFIG_BYTES {
        return Err(ApiError::bad_request("config exceeds 1 MiB"));
    }

    let current_raw = tokio::fs::read_to_string(&state.config_path)
        .await
        .map_err(ApiError::internal)?;
    let current: Value = serde_json::from_str(&current_raw).map_err(ApiError::internal)?;
    restore_redacted_secrets(&mut incoming, &current);
    let parsed = Config::from_value_strict(incoming.clone())
        .map_err(|error| ApiError::bad_request(format!("invalid config: {error}")))?;
    parsed
        .get_provider_config("default")
        .map_err(|error| ApiError::bad_request(format!("invalid default agent: {error}")))?;

    let pretty = serde_json::to_string_pretty(&incoming).map_err(ApiError::internal)? + "\n";
    atomic_write(&state.config_path, pretty.as_bytes()).await?;
    tracing::info!(path = %state.config_path.display(), "Configuration updated from WebUI; reload or restart required");

    let mut response = incoming;
    redact_secrets(&mut response);
    Ok(Json(ConfigResponse {
        config: response,
        path: state.config_path.display().to_string(),
        revision: source_revision(&pretty),
        diagnostics: Vec::new(),
        restart_required: true,
    }))
}

pub async fn cleanup_invalid_config_entries(
    State(state): State<Arc<GatewayState>>,
    Json(request): Json<CleanupInvalidConfigRequest>,
) -> Result<Json<ConfigResponse>, ApiError> {
    let _write_guard = state.config_write_lock.lock().await;
    let raw = tokio::fs::read_to_string(&state.config_path)
        .await
        .map_err(ApiError::internal)?;
    let current_revision = source_revision(&raw);
    if request.revision != current_revision {
        return Err(ApiError::conflict(
            "config.json changed after it was displayed; reload the page before cleaning",
        ));
    }

    let path = state.config_path.clone();
    let context = state.config_load_context.clone();
    let inspected = tokio::task::spawn_blocking(move || {
        Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
            .map_err(|error| error.to_string())
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad_request)?;
    if inspected.source_revision != current_revision {
        return Err(ApiError::conflict(
            "config.json changed while it was being inspected; retry",
        ));
    }

    let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
    let removed = cleanup_invalid_config(&mut value, &inspected.diagnostics);
    let pretty = serde_json::to_string_pretty(&value).map_err(ApiError::internal)? + "\n";
    if removed > 0 {
        atomic_write(&state.config_path, pretty.as_bytes()).await?;
        tracing::info!(
            path = %state.config_path.display(),
            removed,
            "Invalid configuration entries removed from WebUI"
        );
    }

    let post_cleanup = if removed > 0 {
        let path = state.config_path.clone();
        let context = state.config_load_context.clone();
        tokio::task::spawn_blocking(move || {
            Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
                .map_err(|error| error.to_string())
        })
        .await
        .map_err(ApiError::internal)?
        .map_err(ApiError::bad_request)?
    } else {
        inspected
    };
    let mut response = value;
    redact_secrets(&mut response);
    Ok(Json(ConfigResponse {
        config: response,
        path: state.config_path.display().to_string(),
        revision: post_cleanup.source_revision,
        diagnostics: post_cleanup.diagnostics,
        restart_required: removed > 0,
    }))
}

fn is_secret_key(key: &str) -> bool {
    let key = key.to_ascii_lowercase();
    key.contains("api_key")
        || key.contains("secret")
        || key.contains("password")
        || key.ends_with("token")
        || key.ends_with("_token")
        || key == "authorization"
}

fn redact_secrets(value: &mut Value) {
    match value {
        Value::Object(map) => {
            for (key, value) in map {
                if is_secret_key(key) && value.is_string() {
                    *value = Value::String(REDACTED.to_string());
                } else {
                    redact_secrets(value);
                }
            }
        }
        Value::Array(values) => values.iter_mut().for_each(redact_secrets),
        _ => {}
    }
}

fn restore_redacted_secrets(incoming: &mut Value, current: &Value) {
    match (incoming, current) {
        (Value::Object(incoming), Value::Object(current)) => {
            for (key, value) in incoming {
                if is_secret_key(key) && value.as_str() == Some(REDACTED) {
                    if let Some(original) = current.get(key) {
                        *value = original.clone();
                    }
                } else if let Some(original) = current.get(key) {
                    restore_redacted_secrets(value, original);
                }
            }
        }
        (Value::Array(incoming), Value::Array(current)) => {
            for (value, original) in incoming.iter_mut().zip(current) {
                restore_redacted_secrets(value, original);
            }
        }
        _ => {}
    }
}

async fn atomic_write(path: &FsPath, content: &[u8]) -> Result<(), ApiError> {
    let parent = path.parent().unwrap_or_else(|| FsPath::new("."));
    tokio::fs::create_dir_all(parent)
        .await
        .map_err(ApiError::internal)?;
    let temp = parent.join(format!(".picobot-webui-{}.tmp", crate::util::short_id()));
    tokio::fs::write(&temp, content)
        .await
        .map_err(ApiError::internal)?;
    if let Err(error) = tokio::fs::rename(&temp, path).await {
        let _ = tokio::fs::remove_file(&temp).await;
        return Err(ApiError::internal(error));
    }
    Ok(())
}

#[derive(Serialize)]
pub struct ProfileResponse {
    name: String,
    content: String,
    path: String,
}

#[derive(Deserialize)]
pub struct ProfileUpdate {
    content: String,
}

fn profile_path(name: &str) -> Result<PathBuf, ApiError> {
    let file = match name.to_ascii_lowercase().as_str() {
        "user" | "user.md" => "USER.md",
        "agents" | "agents.md" => "AGENTS.md",
        _ => return Err(ApiError::not_found("profile must be USER.md or AGENTS.md")),
    };
    Ok(crate::config::get_user_config_dir().join(file))
}

pub async fn get_profile(Path(name): Path<String>) -> Result<Json<ProfileResponse>, ApiError> {
    let path = profile_path(&name)?;
    let content = tokio::fs::read_to_string(&path)
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(ProfileResponse {
        name,
        content,
        path: path.display().to_string(),
    }))
}

pub async fn put_profile(
    Path(name): Path<String>,
    Json(update): Json<ProfileUpdate>,
) -> Result<Json<ProfileResponse>, ApiError> {
    if update.content.len() > MAX_PROFILE_BYTES {
        return Err(ApiError::bad_request("profile exceeds 256 KiB"));
    }
    let path = profile_path(&name)?;
    atomic_write(&path, update.content.as_bytes()).await?;
    tracing::info!(profile = %name, path = %path.display(), "Assistant profile updated from WebUI");
    Ok(Json(ProfileResponse {
        name,
        content: update.content,
        path: path.display().to_string(),
    }))
}

#[derive(Default, Deserialize)]
pub struct LogsQuery {
    lines: Option<usize>,
    search: Option<String>,
}

#[derive(Serialize)]
pub struct LogsResponse {
    lines: Vec<String>,
    files: Vec<String>,
}

pub async fn get_logs(Query(query): Query<LogsQuery>) -> Result<Json<LogsResponse>, ApiError> {
    let limit = query.lines.unwrap_or(500).clamp(1, 5000);
    let search = query.search.filter(|value| !value.is_empty());
    let log_dir = crate::logging::get_default_log_dir();
    let mut entries = tokio::fs::read_dir(&log_dir)
        .await
        .map_err(ApiError::internal)?;
    let mut paths = Vec::new();
    while let Some(entry) = entries.next_entry().await.map_err(ApiError::internal)? {
        let path = entry.path();
        if path.is_file()
            && path
                .file_name()
                .and_then(|value| value.to_str())
                .is_some_and(|name| name.starts_with("picobot.log"))
        {
            paths.push(path);
        }
    }
    paths.sort();
    paths = paths.into_iter().rev().take(7).collect();
    paths.sort();
    let files = paths
        .iter()
        .filter_map(|path| path.file_name()?.to_str().map(str::to_string))
        .collect();
    let mut output = VecDeque::with_capacity(limit);
    for path in paths {
        let content = read_file_tail(&path, 2 * 1024 * 1024).await?;
        for line in content.lines() {
            if search.as_ref().is_some_and(|needle| {
                !line
                    .to_ascii_lowercase()
                    .contains(&needle.to_ascii_lowercase())
            }) {
                continue;
            }
            if output.len() == limit {
                output.pop_front();
            }
            output.push_back(line.to_string());
        }
    }
    Ok(Json(LogsResponse {
        lines: output.into(),
        files,
    }))
}

async fn read_file_tail(path: &FsPath, max_bytes: u64) -> Result<String, ApiError> {
    let mut file = tokio::fs::File::open(path)
        .await
        .map_err(ApiError::internal)?;
    let len = file.metadata().await.map_err(ApiError::internal)?.len();
    if len > max_bytes {
        file.seek(std::io::SeekFrom::Start(len - max_bytes))
            .await
            .map_err(ApiError::internal)?;
    }
    let mut bytes = Vec::with_capacity(len.min(max_bytes) as usize);
    file.read_to_end(&mut bytes)
        .await
        .map_err(ApiError::internal)?;
    let text = String::from_utf8_lossy(&bytes).into_owned();
    Ok(if len > max_bytes {
        text.find('\n')
            .map_or(text.clone(), |newline| text[newline + 1..].to_string())
    } else {
        text
    })
}

#[derive(Default, Deserialize)]
pub struct LimitQuery {
    limit: Option<usize>,
}

#[derive(serde::Deserialize)]
pub struct AgentRunsQuery {
    session_id: String,
    cursor: Option<String>,
    limit: Option<usize>,
}

fn scheduler_snapshot(jobs: &[crate::storage::ScheduledJob]) -> Value {
    let enabled = jobs.iter().filter(|job| job.enabled).count();
    let failed_jobs = jobs
        .iter()
        .filter(|job| {
            matches!(
                job.last_outcome,
                Some(
                    crate::storage::ScheduledOutcomeKind::Failed
                        | crate::storage::ScheduledOutcomeKind::Refused
                        | crate::storage::ScheduledOutcomeKind::Unknown
                )
            )
        })
        .count();
    let next_run_at = jobs
        .iter()
        .filter(|job| job.enabled)
        .map(|job| job.next_run_at)
        .min();

    json!({
        "jobs": jobs.len(),
        "enabled": enabled,
        "failed_jobs": failed_jobs,
        "next_run_at": next_run_at,
    })
}

fn scheduler_status_error(error: impl std::fmt::Display) -> ApiError {
    ApiError::internal_with_message(error, "failed to load scheduler status")
}

fn channel_snapshot(mut channels: Vec<(String, bool)>) -> Vec<Value> {
    channels.sort_by(|left, right| left.0.cmp(&right.0));
    channels
        .into_iter()
        .map(|(name, running)| {
            json!({
                "name": name,
                "status": if running { "connected" } else { "stopped" },
            })
        })
        .collect()
}

fn sorted_providers(
    mut providers: Vec<crate::observability::metrics::ProviderSnapshot>,
) -> Vec<crate::observability::metrics::ProviderSnapshot> {
    providers.sort_by(|left, right| left.name.cmp(&right.name));
    providers
}

pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
    let reload = state.reload.status();
    let metrics = crate::observability::metrics::global_metrics().snapshot();
    let depths = state.bus().queue_depths();
    let active_lanes = state
        .outbound_lanes
        .load(std::sync::atomic::Ordering::Relaxed);
    let ws_connections = state
        .ws_connections
        .load(std::sync::atomic::Ordering::Relaxed);
    let background_tasks = state.task_supervisor.running_count();
    let sessions_total = state.session_manager.session_count().await;
    let active_turns = state.session_manager.active_turn_count().await;

    let mut channel_states = Vec::new();
    for name in state.channel_manager.list_channel_names().await {
        let running = state
            .channel_manager
            .get_channel(&name)
            .await
            .is_some_and(|channel| channel.is_running());
        channel_states.push((name, running));
    }
    let channels = channel_snapshot(channel_states);

    let jobs = state
        .storage
        .list_scheduled_jobs()
        .await
        .map_err(scheduler_status_error)?;
    let scheduler = scheduler_snapshot(&jobs);
    let mcp = crate::mcp::get_mcp_status()
        .into_iter()
        .map(|status| {
            json!({
                "name": status.name,
                "transport": status.transport,
                "connected": status.connected,
                "error": status.error,
                "tools": status
                    .tools
                    .iter()
                    .map(|tool| json!({
                        "name": tool.name,
                        "description": tool.description,
                        "read_only": tool.read_only,
                        "exclusive": tool.exclusive,
                        "concurrency_safe": tool.concurrency_safe,
                    }))
                    .collect::<Vec<_>>(),
            })
        })
        .collect::<Vec<_>>();

    Ok(Json(json!({
        "generation": reload.generation,
        "version": env!("CARGO_PKG_VERSION"),
        "uptime_secs": crate::gateway::process_uptime_secs(),
        "phase": reload.phase,
        "ws_connections": ws_connections,
        "background_tasks": background_tasks,
        "sessions": {
            "total": sessions_total,
            "active_turns": active_turns,
        },
        "metrics": {
            "tokens_in": metrics.tokens_in,
            "tokens_out": metrics.tokens_out,
            "cost": metrics.cost,
            "tool_calls": metrics.tool_calls,
            "turns": metrics.turns,
            "turn_latency_p95_ms": metrics.turn_latency_p95_ms,
        },
        "bus": {
            "inbound": { "depth": depths.inbound_depth, "cap": depths.inbound_cap },
            "outbound": { "depth": depths.outbound_depth, "cap": depths.outbound_cap },
            "control": { "depth": depths.control_depth, "cap": depths.control_cap },
            "active_lanes": active_lanes,
        },
        "providers": sorted_providers(metrics.providers),
        "channels": channels,
        "scheduler": scheduler,
        "mcp": mcp,
    })))
}

pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
    let metrics = crate::observability::metrics::global_metrics();
    let registry = state.session_manager.tools();
    let mut entries = registry.iter();
    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
    let tools: Vec<Value> = entries
        .into_iter()
        .map(|(name, tool)| {
            let source = if crate::mcp::is_mcp_tool_name(&name) {
                "mcp"
            } else {
                "builtin"
            };
            json!({
                "name": name,
                "description": tool.description(),
                "parameters_schema": tool.parameters_schema(),
                "source": source,
                "read_only": tool.read_only(),
                "exclusive": tool.exclusive(),
                "concurrency_safe": tool.concurrency_safe(),
                "call_count": metrics.tool_call_count(&name),
            })
        })
        .collect();
    Ok(Json(json!({ "tools": tools })))
}

/// List Agent definition files (enabled and disabled) from the resolved
/// definitions directory.  Broken definitions are listed too (with their parse
/// error) and every entry is annotated with any load error from the active
/// catalog generation, so a definition that failed validation can be fixed and
/// re-enabled from the UI instead of silently disappearing.
pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
    let load_errors: std::collections::HashMap<&str, &crate::agent::CatalogEntryError> = state
        .agent_catalog
        .load_errors()
        .iter()
        .map(|error| (error.id.as_str(), error))
        .collect();
    let mut agents = Vec::new();
    let entries = match std::fs::read_dir(&state.agents_dir) {
        Ok(entries) => entries,
        Err(_error) => {
            return Ok(Json(json!({ "agents": [] })));
        }
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("md") {
            continue;
        }
        let (info, parse_error) = crate::agent::definition::parse_definition_lenient(&path);
        let Some(info) = info else {
            continue;
        };
        let fm = &info.frontmatter;
        let load_error = load_errors
            .get(fm.id.as_str())
            .map(|error| error.reason.clone());
        let disabled = load_error.is_some() || parse_error.is_some();
        agents.push(json!({
            "id": fm.id,
            "description": fm.description,
            "enabled": !disabled && fm.enabled,
            "llm_profile": fm.llm_profile,
            "provider": fm.provider,
            "model": fm.model,
            "token_limit": fm.token_limit,
            "max_tool_iterations": fm.max_tool_iterations,
            "tools": fm.tools,
            "delegates": fm.delegates,
            "skills": fm.skills,
            "limits": fm.limits,
            "signal": fm.signal,
            "role_prompt": info.role_prompt,
            "load_error": load_error,
            "parse_error": parse_error,
        }));
    }
    agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
    Ok(Json(json!({ "agents": agents })))
}

/// Create or update a definition file under the definitions directory.
pub async fn put_agent(
    State(state): State<Arc<GatewayState>>,
    Json(body): Json<Value>,
) -> Result<Json<Value>, ApiError> {
    let info = agent_info_from_json(&body)?;
    let fm = &info.frontmatter;

    // Validate referenced provider/model/tools/skills so a broken file is
    // rejected at the API boundary instead of breaking the next reload.
    if let Some(provider) = fm.provider.as_deref()
        && !state.config.providers.contains_key(provider)
    {
        return Err(ApiError::bad_request(format!(
            "unknown provider '{provider}'"
        )));
    }
    if let Some(model) = fm.model.as_deref()
        && !state.config.models.contains_key(model)
    {
        return Err(ApiError::bad_request(format!("unknown model '{model}'")));
    }
    let registry = state.session_manager.tools();
    for tool in &fm.tools {
        let Some(registered) = registry.get(tool) else {
            return Err(ApiError::bad_request(format!("unknown tool '{tool}'")));
        };
        if registered.runtime_injected() && tool != "get_skill" {
            return Err(ApiError::bad_request(format!(
                "tool '{tool}' is runtime-injected and cannot be declared"
            )));
        }
    }
    let loaded_skills: std::collections::HashSet<String> = state
        .session_manager
        .skills_loader()
        .list_skills()
        .into_iter()
        .map(|(name, _)| name)
        .collect();
    for skill in &fm.skills {
        if !loaded_skills.contains(skill) {
            return Err(ApiError::bad_request(format!("unknown skill '{skill}'")));
        }
    }
    if !fm.skills.is_empty() && !fm.tools.iter().any(|t| t == "get_skill") {
        return Err(ApiError::bad_request(
            "skills require the get_skill tool in the definition",
        ));
    }

    let content = crate::agent::definition::serialize_definition(&info);
    tokio::fs::create_dir_all(&state.agents_dir)
        .await
        .map_err(ApiError::internal)?;
    let path = state.agents_dir.join(format!("{}.md", fm.id));
    tokio::fs::write(&path, content)
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(json!({ "id": fm.id, "saved": true })))
}

/// Delete a definition file.
pub async fn delete_agent(
    State(state): State<Arc<GatewayState>>,
    Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
    crate::agent::definition::validate_agent_id(&id)
        .map_err(|error| ApiError::bad_request(error.to_string()))?;
    let path = state.agents_dir.join(format!("{id}.md"));
    match tokio::fs::remove_file(&path).await {
        Ok(()) => Ok(Json(json!({ "deleted": id }))),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            Err(ApiError::not_found(format!("agent {id} not found")))
        }
        Err(error) => Err(ApiError::internal(error)),
    }
}

/// Available providers/models/tools/skills for the editor UI.
pub async fn get_agent_options(
    State(state): State<Arc<GatewayState>>,
) -> Result<Json<Value>, ApiError> {
    let providers: Vec<String> = state.config.providers.keys().cloned().collect();
    let models: Vec<Value> = state
        .config
        .models
        .iter()
        .map(|(name, model)| {
            json!({
                "name": name,
                "model_id": model.model_id,
                "token_limit": model.token_limit,
            })
        })
        .collect();
    let registry = state.session_manager.tools();
    let mut tools: Vec<Value> = registry
        .iter()
        .into_iter()
        // get_skill is the one runtime-injected tool that may be declared in
        // a definition's `tools` list (it turns on the scoped skill wrapper),
        // so it must be offered in the editor.
        .filter(|(name, tool)| {
            (!tool.runtime_injected() || name == "get_skill") && !crate::mcp::is_mcp_tool_name(name)
        })
        .map(|(name, tool)| json!({ "name": name, "description": tool.description() }))
        .collect();
    tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
    let skills: Vec<String> = state
        .session_manager
        .skills_loader()
        .list_skills()
        .into_iter()
        .map(|(name, _)| name)
        .collect();
    Ok(Json(json!({
        "providers": providers,
        "models": models,
        "tools": tools,
        "skills": skills,
    })))
}

fn agent_info_from_json(
    body: &Value,
) -> Result<crate::agent::definition::AgentDefinitionInfo, ApiError> {
    use crate::agent::definition::{AgentDefinitionInfo, AgentFrontmatter, AgentLimits};
    let id = body
        .get("id")
        .and_then(Value::as_str)
        .ok_or_else(|| ApiError::bad_request("missing required field: id"))?
        .to_string();
    crate::agent::definition::validate_agent_id(&id)
        .map_err(|error| ApiError::bad_request(error.to_string()))?;
    let description = body
        .get("description")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    let role_prompt = body
        .get("role_prompt")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    if role_prompt.trim().is_empty() {
        return Err(ApiError::bad_request("role_prompt must not be empty"));
    }
    let provider = body
        .get("provider")
        .and_then(Value::as_str)
        .map(str::to_string);
    let model = body
        .get("model")
        .and_then(Value::as_str)
        .map(str::to_string);
    let llm_profile = body
        .get("llm_profile")
        .and_then(Value::as_str)
        .map(str::to_string);
    if provider.is_none() && model.is_none() && llm_profile.as_deref().is_none_or(str::is_empty) {
        return Err(ApiError::bad_request(
            "either provider+model or llm_profile is required",
        ));
    }
    if provider.is_some() != model.is_some() {
        return Err(ApiError::bad_request(
            "provider and model must be set together",
        ));
    }
    let info = AgentDefinitionInfo {
        frontmatter: AgentFrontmatter {
            id,
            description,
            llm_profile: llm_profile.filter(|v| !v.is_empty()),
            provider,
            model,
            token_limit: body
                .get("token_limit")
                .and_then(Value::as_u64)
                .map(|v| v as usize),
            max_tool_iterations: body
                .get("max_tool_iterations")
                .and_then(Value::as_u64)
                .map(|v| v as usize),
            enabled: body.get("enabled").and_then(Value::as_bool).unwrap_or(true),
            tools: string_array(body, "tools"),
            delegates: body
                .get("delegates")
                .and_then(Value::as_array)
                .map(|items| {
                    items
                        .iter()
                        .filter_map(Value::as_str)
                        .map(str::to_string)
                        .collect()
                }),
            skills: string_array(body, "skills"),
            limits: body
                .get("limits")
                .and_then(|v| serde_json::from_value::<AgentLimits>(v.clone()).ok())
                .unwrap_or_default(),
            signal: body
                .get("signal")
                .and_then(|v| serde_json::from_value(v.clone()).ok()),
        },
        role_prompt,
    };
    Ok(info)
}

fn string_array(body: &Value, key: &str) -> Vec<String> {
    body.get(key)
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(str::to_string)
                .collect()
        })
        .unwrap_or_default()
}

pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
    let loader = state.session_manager.skills_loader();
    let skills: Vec<Value> = loader
        .get_loaded_skills()
        .iter()
        .map(|s| {
            json!({
                "name": s.name,
                "description": s.description,
                "always": s.always,
                "enabled": loader.is_enabled(&s.name),
                "source": loader.source_of(s.path.as_deref()),
            })
        })
        .collect();
    Ok(Json(json!({ "skills": skills })))
}

#[derive(Deserialize)]
pub struct SkillEnableUpdate {
    enabled: bool,
}

/// Enable or disable a skill. The change is persisted to the skills state
/// file and takes effect immediately (disabled skills are excluded from
/// prompts, listings, and `get_skill`).
pub async fn put_skill_enabled(
    State(state): State<Arc<GatewayState>>,
    Path(name): Path<String>,
    Json(update): Json<SkillEnableUpdate>,
) -> Result<Json<Value>, ApiError> {
    let loader = state.session_manager.skills_loader();
    let known = loader.get_loaded_skills().iter().any(|s| s.name == name);
    if !known {
        return Err(ApiError::not_found(format!("skill {name} not found")));
    }
    loader
        .set_enabled(&name, update.enabled)
        .map_err(ApiError::bad_request)?;
    let skills: Vec<Value> = loader
        .get_loaded_skills()
        .iter()
        .map(|s| {
            json!({
                "name": s.name,
                "description": s.description,
                "always": s.always,
                "enabled": loader.is_enabled(&s.name),
                "source": loader.source_of(s.path.as_deref()),
            })
        })
        .collect();
    Ok(Json(json!({ "skills": skills })))
}

pub async fn get_tasks(
    State(state): State<Arc<GatewayState>>,
    Query(query): Query<LimitQuery>,
) -> Result<Json<Value>, ApiError> {
    let limit = query.limit.unwrap_or(100).clamp(1, 500);
    let runs = state
        .storage
        .list_all_agent_runs(None, limit as i64)
        .await
        .map_err(ApiError::internal)?;
    let tasks: Vec<Value> = runs
        .into_iter()
        .map(|run| {
            json!({
                "source": "agent_run",
                "id": run.id,
                "parent_run_id": run.parent_run_id,
                "session_id": run.root_session_id,
                "agent_id": run.agent_id,
                "mode": run.mode.as_str(),
                "depth": run.depth,
                "prompt": run.task,
                "status": run.status.as_str(),
                "result": run.result,
                "error": run.error,
                "tool_calls_count": run.tool_calls_count,
                "iterations": run.iterations,
                "started_at": run.started_at,
                "finished_at": run.finished_at,
                "created_at": run.created_at,
            })
        })
        .collect();
    Ok(Json(json!({ "tasks": tasks })))
}

/// Session-scoped durable run listing with `(created_at,id)` cursor paging.
pub async fn get_agent_runs(
    State(state): State<Arc<GatewayState>>,
    Query(query): Query<AgentRunsQuery>,
) -> Result<Json<Value>, ApiError> {
    let Some(coordinator) = state.session_manager.agent_coordinator() else {
        return Ok(Json(json!({
            "revision": 0,
            "runs": [],
            "next_cursor": Value::Null,
        })));
    };
    let cursor = query.cursor.as_deref().and_then(|cursor| {
        let (created_at, id) = cursor.split_once(':')?;
        Some((created_at.parse::<i64>().ok()?, id.to_string()))
    });
    let limit = query.limit.unwrap_or(100).clamp(1, 200) as i64;
    let (revision, runs, next_cursor) = coordinator
        .list_runs_for_session(&query.session_id, cursor, limit)
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(json!({
        "revision": revision,
        "runs": runs,
        "next_cursor": next_cursor,
    })))
}

pub async fn get_agent_run(
    State(state): State<Arc<GatewayState>>,
    Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
    let Some(_coordinator) = state.session_manager.agent_coordinator() else {
        return Err(ApiError::not_found("run not found".to_string()));
    };
    let run = state
        .storage
        .get_agent_run(&id)
        .await
        .map_err(ApiError::internal)?;
    let Some(run) = run else {
        return Err(ApiError::not_found(format!("run {id} not found")));
    };
    let session_id = run.root_session_id.clone();
    let transcript = state
        .storage
        .list_agent_run_messages(&id, 10_000)
        .await
        .map_err(ApiError::internal)?
        .into_iter()
        .map(crate::protocol::AgentTranscriptMessage::from)
        .collect::<Vec<_>>();
    Ok(Json(json!({
        "run": crate::protocol::AgentRunView::from_record(&run, 100_000),
        "session_id": session_id,
        "transcript": transcript,
    })))
}

pub async fn get_agent_run_events(
    State(state): State<Arc<GatewayState>>,
    Path(id): Path<String>,
    Query(query): Query<LimitQuery>,
) -> Result<Json<Value>, ApiError> {
    let Some(coordinator) = state.session_manager.agent_coordinator() else {
        return Ok(Json(json!({ "events": [] })));
    };
    let limit = query.limit.unwrap_or(100).clamp(1, 200) as i64;
    let events = coordinator
        .list_run_events(&id, limit)
        .await
        .map_err(ApiError::internal)?
        .iter()
        .map(crate::protocol::AgentEventView::from_record)
        .collect::<Vec<_>>();
    Ok(Json(json!({ "events": events })))
}

pub async fn cancel_agent_run(
    State(state): State<Arc<GatewayState>>,
    Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
    let Some(coordinator) = state.session_manager.agent_coordinator() else {
        return Err(ApiError::not_found("run not found".to_string()));
    };
    let Some(run) = state
        .storage
        .get_agent_run(&id)
        .await
        .map_err(ApiError::internal)?
    else {
        return Err(ApiError::not_found(format!("run {id} not found")));
    };
    let cancelled = coordinator
        .cancel_run_for_session(&run.root_session_id, &id, "cancelled from management UI")
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(json!({ "cancelled": cancelled, "run_id": id })))
}

pub async fn get_jobs(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
    let jobs = state
        .storage
        .list_scheduled_jobs()
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(json!({ "jobs": jobs })))
}

pub async fn get_job_runs(
    State(state): State<Arc<GatewayState>>,
    Path(id): Path<String>,
    Query(query): Query<LimitQuery>,
) -> Result<Json<Value>, ApiError> {
    state
        .storage
        .get_scheduled_job(&id)
        .await
        .map_err(|error| ApiError::not_found(error.to_string()))?;
    let limit = query.limit.unwrap_or(50).clamp(1, 500);
    let runs = state
        .storage
        .list_scheduled_job_runs(&id, limit)
        .await
        .map_err(ApiError::internal)?;
    let runs = runs
        .into_iter()
        .map(|run| {
            json!({
                "id": run.id,
                "job_id": run.job_id,
                "scheduled_for": run.scheduled_for,
                "started_at": run.started_at,
                "finished_at": run.finished_at,
                "status": run.status,
                "outcome": run.outcome,
                "message": run.message,
                "diagnostic": run.diagnostic,
                "duration_ms": run.duration_ms,
                "delivery_status": run.delivery_status,
                "delivery_attempts": run.delivery_attempts,
                "delivery_error": run.delivery_error,
            })
        })
        .collect::<Vec<_>>();
    Ok(Json(json!({ "runs": runs })))
}

#[derive(Default, Deserialize)]
pub struct MemoriesQuery {
    query: Option<String>,
    category: Option<String>,
    session_id: Option<String>,
    limit: Option<usize>,
}

pub async fn get_memories(
    State(state): State<Arc<GatewayState>>,
    Query(query): Query<MemoriesQuery>,
) -> Result<Json<Value>, ApiError> {
    let category = query
        .category
        .as_deref()
        .filter(|value| !value.is_empty())
        .map(|value| {
            MemoryCategory::parse(value)
                .ok_or_else(|| ApiError::bad_request("category must be knowledge or timeline"))
        })
        .transpose()?;
    let limit = query.limit.unwrap_or(100).clamp(1, 500);
    let session_id = query
        .session_id
        .as_deref()
        .filter(|value| !value.is_empty());
    let memories = if let Some(search) = query
        .query
        .as_deref()
        .filter(|value| !value.trim().is_empty())
    {
        state
            .storage
            .search_memories(search, category.as_ref(), session_id, limit)
            .await
    } else {
        state
            .storage
            .list_memories(category.as_ref(), session_id, limit)
            .await
    }
    .map_err(ApiError::internal)?;
    Ok(Json(json!({ "memories": memories })))
}

#[derive(Deserialize)]
pub struct PutMemoryBody {
    content: String,
    importance: Option<f64>,
}

pub async fn put_memory(
    State(state): State<Arc<GatewayState>>,
    Path(key): Path<String>,
    Json(body): Json<PutMemoryBody>,
) -> Result<Json<Value>, ApiError> {
    let existing = state
        .storage
        .get_memory_by_key(&key)
        .await
        .map_err(ApiError::internal)?
        .ok_or_else(|| ApiError::not_found("memory not found"))?;
    let mut updated = existing;
    updated.content = body.content;
    if let Some(importance) = body.importance {
        updated.importance = importance.clamp(0.0, 1.0);
    }
    updated.updated_at = chrono::Utc::now().to_rfc3339();
    state
        .storage
        .upsert_memory(&updated)
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(json!({ "updated": true, "key": key })))
}

pub async fn delete_memory(
    State(state): State<Arc<GatewayState>>,
    Path(key): Path<String>,
) -> Result<Json<Value>, ApiError> {
    state
        .storage
        .delete_memory(&key)
        .await
        .map_err(ApiError::internal)?;
    Ok(Json(json!({ "deleted": true })))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn scheduled_job(
        id: &str,
        enabled: bool,
        next_run_at: i64,
        last_outcome: Option<crate::storage::ScheduledOutcomeKind>,
    ) -> crate::storage::ScheduledJob {
        crate::storage::ScheduledJob {
            id: id.to_string(),
            name: id.to_string(),
            schedule: crate::scheduler::Schedule::Every { every_ms: 60_000 },
            prompt: String::new(),
            agent_id: None,
            channel: "cli_chat".to_string(),
            chat_id: "test".to_string(),
            delivery_policy: crate::storage::DeliveryPolicy::Never,
            enabled,
            next_run_at,
            last_run_at: None,
            last_outcome,
            created_at: 0,
            updated_at: 0,
            locked_at: None,
            lock_owner: None,
            lease_until: None,
        }
    }

    #[test]
    fn scheduler_snapshot_classifies_failures_and_next_enabled_run() {
        let jobs = vec![
            scheduled_job(
                "healthy",
                true,
                300,
                Some(crate::storage::ScheduledOutcomeKind::Ok),
            ),
            scheduled_job(
                "error",
                true,
                200,
                Some(crate::storage::ScheduledOutcomeKind::Failed),
            ),
            scheduled_job(
                "refused",
                false,
                100,
                Some(crate::storage::ScheduledOutcomeKind::Refused),
            ),
            scheduled_job(
                "unknown",
                true,
                400,
                Some(crate::storage::ScheduledOutcomeKind::Unknown),
            ),
            scheduled_job("other", false, 50, None),
        ];

        assert_eq!(
            scheduler_snapshot(&jobs),
            json!({
                "jobs": 5,
                "enabled": 3,
                "failed_jobs": 3,
                "next_run_at": 200,
            })
        );
    }

    #[test]
    fn scheduler_snapshot_has_null_next_run_without_enabled_jobs() {
        let jobs = vec![scheduled_job("disabled", false, 100, None)];

        assert_eq!(
            scheduler_snapshot(&jobs),
            json!({
                "jobs": 1,
                "enabled": 0,
                "failed_jobs": 0,
                "next_run_at": null,
            })
        );
    }

    #[test]
    fn channel_snapshot_is_sorted_and_maps_running_state() {
        let channels = channel_snapshot(vec![
            ("zeta".to_string(), false),
            ("alpha".to_string(), true),
        ]);

        assert_eq!(
            channels,
            vec![
                json!({ "name": "alpha", "status": "connected" }),
                json!({ "name": "zeta", "status": "stopped" }),
            ]
        );
    }

    #[test]
    fn provider_snapshot_is_sorted_by_name() {
        let provider = |name: &str| crate::observability::metrics::ProviderSnapshot {
            name: name.to_string(),
            model: String::new(),
            status: "ok".to_string(),
            latency_ms: 0,
            latencies: Vec::new(),
            tokens_in: 0,
            tokens_out: 0,
            cost: 0.0,
        };

        let providers = sorted_providers(vec![provider("zeta"), provider("alpha")]);
        assert_eq!(
            providers
                .iter()
                .map(|provider| provider.name.as_str())
                .collect::<Vec<_>>(),
            vec!["alpha", "zeta"]
        );
    }

    #[test]
    fn scheduler_status_error_hides_internal_details() {
        let error = scheduler_status_error("database contained private payload");

        assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
        assert_eq!(error.message, "failed to load scheduler status");
        assert!(!error.message.contains("private payload"));
    }

    #[test]
    fn secrets_are_redacted_and_restored() {
        let current = json!({"api_key":"real", "nested":{"access_token":"token"}, "safe":"yes"});
        let mut shown = current.clone();
        redact_secrets(&mut shown);
        assert_eq!(shown["api_key"], REDACTED);
        assert_eq!(shown["nested"]["access_token"], REDACTED);
        restore_redacted_secrets(&mut shown, &current);
        assert_eq!(shown, current);
    }

    #[test]
    fn profile_names_are_allowlisted() {
        assert!(profile_path("USER.md").unwrap().ends_with("USER.md"));
        assert!(profile_path("../config.json").is_err());
    }

    #[tokio::test]
    async fn embedded_webui_has_security_headers() {
        let response = webui_index().await;
        assert_eq!(
            response.headers().get("X-Content-Type-Options").unwrap(),
            "nosniff"
        );
        assert!(response.headers().contains_key("Content-Security-Policy"));
    }

    #[test]
    fn attachment_headers_are_safely_encoded_and_inline_is_allowlisted() {
        assert_eq!(
            encode_header_filename("报告 1.pdf"),
            "%E6%8A%A5%E5%91%8A%201.pdf"
        );
        assert!(inline_mime_allowed("image/png"));
        assert!(inline_mime_allowed("image/bmp"));
        assert!(!inline_mime_allowed("image/svg+xml"));
        assert!(!inline_mime_allowed("text/html"));
    }
}
