use crate::context::RequestContext;
use crate::error::WebError;
use axum::body::Body;
use axum::extract::{Extension, Path, Query};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
use axum::Json;
use futures_util::TryStreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::process::Command;
use std::time::Duration;

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionRequest {
    workspace_id: Option<String>,
    document_id: Option<String>,
    trace_id: Option<String>,
    title: Option<String>,
    profile: Option<String>,
}

pub async fn list_sessions(
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    let mut path = "/api/hermes/sessions".to_string();
    if !query.is_empty() {
        let params = query
            .iter()
            .map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value)))
            .collect::<Vec<_>>()
            .join("&");
        path.push('?');
        path.push_str(&params);
    }
    proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
}

pub async fn create_session(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<CreateSessionRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let trace_id = payload
        .trace_id
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| context.trace.trace_id.clone());
    let document_id = payload
        .document_id
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("current");
    let session_id = stable_session_id(document_id, &trace_id);
    let profile = payload
        .profile
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("default");
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "sessionId": session_id,
            "workspaceId": payload.workspace_id,
            "documentId": payload.document_id,
            "profile": profile,
            "title": payload.title.unwrap_or_else(|| "当前页问答".into()),
            "traceId": trace_id,
            "persistence": "hermes_on_first_run"
        })),
    ))
}

pub async fn list_profiles(
    Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::GET,
        &upstream,
        "/api/hermes/profiles",
        None,
    )
    .await
}

pub async fn get_profile(
    Extension(context): Extension<RequestContext>,
    Path(profile_name): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::GET,
        &upstream,
        &format!("/api/hermes/profiles/{}", url_escape(&profile_name)),
        None,
    )
    .await
}

pub async fn switch_active_profile(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::PUT,
        &upstream,
        "/api/hermes/profiles/active",
        Some(payload),
    )
    .await
}

pub async fn get_profile_memory(
    Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::GET,
        &upstream,
        "/api/hermes/memory",
        None,
    )
    .await
}

pub async fn save_profile_memory(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::POST,
        &upstream,
        "/api/hermes/memory",
        Some(payload),
    )
    .await
}

pub async fn list_skills(
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    let mut path = "/api/hermes/skills".to_string();
    if !query.is_empty() {
        let params = query
            .iter()
            .map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value)))
            .collect::<Vec<_>>()
            .join("&");
        path.push('?');
        path.push_str(&params);
    }
    proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
}

pub async fn toggle_skill(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::PUT,
        &upstream,
        "/api/hermes/skills/toggle",
        Some(payload),
    )
    .await
}

pub async fn get_session(
    Extension(context): Extension<RequestContext>,
    Path(session_id): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    if let Some(session) = load_session_from_hermes_cli(&session_id).await {
        return Ok((
            StatusCode::OK,
            stamp_client_headers(),
            Json(json!({
                "ok": true,
                "traceId": context.trace.trace_id,
                "sessionId": session_id,
                "session": session
            })),
        ));
    }
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::GET,
        &upstream,
        &format!("/api/hermes/sessions/{}", url_escape(&session_id)),
        None,
    )
    .await
}

async fn load_session_from_hermes_cli(session_id: &str) -> Option<Value> {
    let session_id = session_id.to_string();
    tokio::task::spawn_blocking(move || {
        let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN")
            .ok()
            .filter(|value| !value.trim().is_empty())
            .unwrap_or_else(|| "hermes".into());
        let output = Command::new(hermes_bin)
            .args(["sessions", "export", "--session-id", &session_id, "-"])
            .output()
            .ok()?;
        if !output.status.success() {
            return None;
        }
        let stdout = String::from_utf8(output.stdout).ok()?;
        stdout
            .lines()
            .find_map(|line| serde_json::from_str::<Value>(line).ok())
    })
    .await
    .ok()
    .flatten()
}

pub async fn create_run(
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    let upstream_body = build_run_upstream_body(&context, payload)?;
    proxy_json(
        &context,
        reqwest::Method::POST,
        &upstream,
        "/v1/runs",
        Some(upstream_body),
    )
    .await
}

pub async fn stream_events(
    Extension(context): Extension<RequestContext>,
    Path(run_id): Path<String>,
) -> Result<Response, WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return Err(hermes_unconfigured_error(&context));
    };
    let url = upstream_url(
        &upstream,
        &format!("/v1/runs/{}/events", url_escape(&run_id)),
    )?;
    let mut request = reqwest::Client::builder()
        .timeout(Duration::from_secs(1800))
        .build()
        .map_err(|error| {
            WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context)
        })?
        .get(url);
    if let Some(api_key) = configured_api_key() {
        request = request.bearer_auth(api_key);
    }
    let upstream_response = request.send().await.map_err(|error| {
        WebError::bad_gateway_code(
            "hermes_client_upstream_unavailable",
            format!("Hermes events upstream 连接失败: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    if !upstream_response.status().is_success() {
        let status = upstream_response.status();
        let text = upstream_response.text().await.unwrap_or_default();
        return Err(upstream_error(&context, status, text));
    }

    let stream = upstream_response.bytes_stream().map_err(|error| {
        std::io::Error::new(
            std::io::ErrorKind::Other,
            format!("Hermes events stream 读取失败: {error}"),
        )
    });
    let mut response = Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
        .header(header::CACHE_CONTROL, "no-cache, no-transform")
        .header("x-accel-buffering", "no")
        .body(Body::from_stream(stream))
        .map_err(|error| WebError::internal(format!("Hermes events 响应构造失败: {error}")))?;
    stamp_client_headers_into(response.headers_mut());
    Ok(response)
}

pub async fn abort_run(
    Extension(context): Extension<RequestContext>,
    Path(run_id): Path<String>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::POST,
        &upstream,
        &format!("/v1/runs/{}/stop", url_escape(&run_id)),
        Some(payload),
    )
    .await
}

pub async fn list_models(
    Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let Some(upstream) = configured_upstream() else {
        return hermes_unconfigured(&context);
    };
    proxy_json(
        &context,
        reqwest::Method::GET,
        &upstream,
        "/v1/models",
        None,
    )
    .await
}

pub async fn list_tools(
    Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "tools": [
                {
                    "name": "mnote.page.get",
                    "scope": "page.read",
                    "schemaVersion": "mnote.hermes_tool.v1",
                    "status": "planned_by_task_e"
                }
            ]
        })),
    ))
}

fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
    let has_actor = context.auth.actor_id.trim() != "anonymous";
    if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
        return Ok(());
    }
    Err(WebError::new(
        StatusCode::UNAUTHORIZED,
        "hermes_client_unauthorized",
        "页面 AI Hermes client 需要登录后访问",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"))
}

fn configured_upstream() -> Option<String> {
    std::env::var("MNOTE_WEB_HERMES_UPSTREAM_URL")
        .ok()
        .or_else(|| std::env::var("MNOTE_HERMES_UPSTREAM_URL").ok())
        .map(|value| value.trim().trim_end_matches('/').to_string())
        .filter(|value| !value.is_empty())
}

fn configured_api_key() -> Option<String> {
    std::env::var("MNOTE_WEB_HERMES_API_KEY")
        .ok()
        .or_else(|| std::env::var("HERMES_API_SERVER_KEY").ok())
        .or_else(|| std::env::var("API_SERVER_KEY").ok())
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<Value, WebError> {
    let message = payload
        .get("message")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| {
            payload
                .get("messages")
                .and_then(Value::as_array)
                .and_then(|messages| messages.last())
                .and_then(|message| message.get("content"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned)
        })
        .ok_or_else(|| {
            WebError::bad_request_code("hermes_client_bad_request", "缺少 message")
                .with_context(context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
        })?;
    let document_id = payload
        .get("documentId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("current");
    let trace_id = payload
        .get("traceId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&context.trace.trace_id);
    let session_id = payload
        .get("sessionId")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| stable_session_id(document_id, trace_id));

    let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null);
    let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null);
    let profile = payload
        .get("profile")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let instructions = json!({
        "role": "mnote_page_ai_context",
        "workspaceId": workspace_id,
        "documentId": document_id,
        "profile": profile.unwrap_or("default"),
        "actorId": context.auth.actor_id,
        "actorType": context.auth.actor_type,
        "sessionId": session_id,
        "traceId": trace_id,
        "toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时，必须透传 workspaceId、documentId、actorId、sessionId 和 traceId；读取页面标题或页面设置时调用 mnote_page_get，并传 includeBody=false、includeOptions=true、includeBlocks=false；需要正文摘要时才传 includeBody=true。不要只依据 pageContext 猜测。",
        "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
        "selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
        "pageContext": page_context
    })
    .to_string();

    let mut body = json!({
        "input": message,
        "session_id": session_id,
        "instructions": instructions
    });
    if let Some(model) = payload
        .get("model")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
    {
        body["model"] = Value::String(model.to_string());
    }
    if let Some(profile) = profile {
        body["profile"] = Value::String(profile.to_string());
    }
    Ok(body)
}

async fn proxy_json(
    context: &RequestContext,
    method: reqwest::Method,
    upstream: &str,
    path: &str,
    body: Option<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let url = upstream_url(upstream, path)?;
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(1800))
        .build()
        .map_err(|error| {
            WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context)
        })?;
    let mut request = client.request(method, url);
    if let Some(api_key) = configured_api_key() {
        request = request.bearer_auth(api_key);
    }
    if let Some(body) = body {
        request = request.json(&body);
    }
    let response = request.send().await.map_err(|error| {
        WebError::bad_gateway_code(
            "hermes_client_upstream_unavailable",
            format!("Hermes upstream 连接失败: {error}"),
        )
        .with_context(context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
    })?;
    let status = response.status();
    let text = response.text().await.unwrap_or_default();
    if !status.is_success() {
        return Err(upstream_error(context, status, text));
    }
    let payload = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| {
        json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "raw": text
        })
    });
    Ok((
        StatusCode::OK,
        stamp_client_headers(),
        Json(normalize_success_payload(context, payload)),
    ))
}

fn normalize_success_payload(context: &RequestContext, payload: Value) -> Value {
    if payload.get("ok").is_some() {
        payload
    } else {
        json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "upstream": payload
        })
    }
}

fn upstream_error(context: &RequestContext, status: reqwest::StatusCode, text: String) -> WebError {
    let (response_status, code) = match status {
        reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => (
            StatusCode::BAD_GATEWAY,
            "hermes_client_upstream_unauthorized",
        ),
        reqwest::StatusCode::TOO_MANY_REQUESTS => (
            StatusCode::TOO_MANY_REQUESTS,
            "hermes_client_upstream_rate_limited",
        ),
        status if status.is_server_error() => (
            StatusCode::BAD_GATEWAY,
            "hermes_client_upstream_unavailable",
        ),
        _ => (StatusCode::BAD_GATEWAY, "hermes_client_upstream_error"),
    };
    WebError::new(
        response_status,
        code,
        format!(
            "Hermes upstream 返回 HTTP {}: {}",
            status.as_u16(),
            text.chars().take(600).collect::<String>()
        ),
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
}

fn hermes_unconfigured(
    context: &RequestContext,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    Err(hermes_unconfigured_error(context))
}

fn hermes_unconfigured_error(context: &RequestContext) -> WebError {
    WebError::service_unavailable_code(
        "hermes_client_unconfigured",
        "Hermes client proxy 未配置 MNOTE_WEB_HERMES_UPSTREAM_URL",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
}

fn upstream_url(upstream: &str, path: &str) -> Result<String, WebError> {
    let url = format!(
        "{}/{}",
        upstream.trim_end_matches('/'),
        path.trim_start_matches('/')
    );
    reqwest::Url::parse(&url)
        .map(|url| url.to_string())
        .map_err(|error| WebError::internal(format!("Hermes upstream URL 无效: {error}")))
}

fn stable_session_id(document_id: &str, trace_id: &str) -> String {
    format!(
        "mnote_{}_{}",
        sanitize_id_part(document_id),
        sanitize_id_part(trace_id)
    )
}

fn sanitize_id_part(value: &str) -> String {
    let sanitized = value
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    if sanitized.is_empty() {
        "current".into()
    } else {
        sanitized
    }
}

fn url_escape(value: &str) -> String {
    value
        .bytes()
        .flat_map(|byte| match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                vec![byte as char]
            }
            _ => format!("%{byte:02X}").chars().collect(),
        })
        .collect()
}

fn stamp_client_headers() -> HeaderMap {
    let mut headers = HeaderMap::new();
    stamp_client_headers_into(&mut headers);
    headers
}

fn stamp_client_headers_into(headers: &mut HeaderMap) {
    if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web"));
    }
    if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_CLIENT_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web-hermes-client"));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::Request;
    use std::sync::{Mutex, OnceLock};
    use tower::util::ServiceExt;

    fn env_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    fn app() -> axum::Router {
        build_app(AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    #[tokio::test]
    async fn hermes_client_unauthenticated_requests_return_401() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .body(Body::from(json!({"documentId":"doc_1"}).to_string()))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("hermes_client_unauthorized")
        );
    }

    #[tokio::test]
    async fn hermes_client_unconfigured_run_returns_stable_error() {
        let _guard = env_lock().lock().expect("env lock");
        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/runs")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_1",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "message": "ping",
                            "traceId": "trace_1"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("hermes_client_unconfigured")
        );
    }

    #[tokio::test]
    async fn hermes_client_session_create_does_not_require_upstream_or_store_chat() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_1",
                            "documentId": "doc_1",
                            "traceId": "trace_1",
                            "title": "当前页问答"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
        assert_eq!(payload["persistence"], "hermes_on_first_run");
        assert!(payload.get("messages").is_none());
    }

    #[tokio::test]
    async fn hermes_client_session_create_carries_agent_profile_without_storing_chat() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/client/sessions")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_1",
                            "documentId": "doc_1",
                            "traceId": "trace_1",
                            "title": "当前页问答",
                            "profile": "chemist"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
        assert_eq!(payload["profile"], "chemist");
        assert!(payload.get("messages").is_none());
    }

    #[test]
    fn hermes_client_run_body_carries_page_context_into_run_input() {
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &HeaderMap::new(),
        );
        let body = build_run_upstream_body(
            &context,
            json!({
                "workspaceId": "ws_1",
                "documentId": "doc_1",
                "sessionId": "sess_1",
                "message": "概括当前页面",
                "pageContext": {"title": "页面标题"},
                "selectedBlockId": "block_1",
                "selectedText": "选中文本",
                "traceId": "trace_1"
            }),
        )
        .expect("body");
        assert_eq!(body["input"], "概括当前页面");
        assert_eq!(body["session_id"], "sess_1");
        let instructions = body["instructions"].as_str().expect("instructions");
        assert!(instructions.contains("\"workspaceId\":\"ws_1\""));
        assert!(instructions.contains("\"documentId\":\"doc_1\""));
        assert!(instructions.contains("\"title\":\"页面标题\""));
        assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
    }

    #[test]
    fn hermes_client_run_body_carries_agent_profile() {
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/client/runs".parse().expect("uri"),
            &HeaderMap::new(),
        );
        let body = build_run_upstream_body(
            &context,
            json!({
                "workspaceId": "ws_1",
                "documentId": "doc_1",
                "sessionId": "sess_1",
                "message": "用当前 agent 回答",
                "profile": "chemist",
                "traceId": "trace_1"
            }),
        )
        .expect("body");
        assert_eq!(body["profile"], "chemist");
        let instructions = body["instructions"].as_str().expect("instructions");
        assert!(instructions.contains("\"profile\":\"chemist\""));
    }

    #[tokio::test]
    async fn hermes_client_profile_skill_and_memory_routes_exist_with_stable_unconfigured_error() {
        let _guard = env_lock().lock().expect("env lock");
        std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
        std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
        let cases = [
            ("GET", "/api/hermes/client/profiles", None),
            ("GET", "/api/hermes/client/profiles/chemist", None),
            ("GET", "/api/hermes/client/profile-memory?profile=chemist", None),
            ("GET", "/api/hermes/client/skills?profile=chemist", None),
            (
                "PUT",
                "/api/hermes/client/profiles/active",
                Some(json!({"name": "chemist"})),
            ),
            (
                "POST",
                "/api/hermes/client/profile-memory",
                Some(json!({"profile": "chemist", "section": "soul", "content": "test"})),
            ),
            (
                "PUT",
                "/api/hermes/client/skills/toggle",
                Some(json!({"name": "chem", "enabled": true})),
            ),
        ];
        for (method, uri, body) in cases {
            let request = Request::builder()
                .method(method)
                .uri(uri)
                .header("content-type", "application/json")
                .header("x-mnote-actor-id", "user_1")
                .body(Body::from(
                    body.unwrap_or_else(|| json!({})).to_string(),
                ))
                .expect("request");
            let response = app().oneshot(request).await.expect("response");
            assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
            assert_eq!(
                response
                    .headers()
                    .get("x-error-code")
                    .and_then(|value| value.to_str().ok()),
                Some("hermes_client_unconfigured"),
                "{uri}"
            );
        }
    }
}
