//! JSON-RPC E2E tests for the embeddings domain.
//!
//! Spins up the core HTTP router against a temp workspace and exercises the
//! `openhuman.embeddings_*` controller surface end-to-end. No real Voyage /
//! OpenAI / Cohere calls are made — tests either use the "none" noop provider
//! or assert error shapes from providers that require live credentials.
//!
//! Run with: `cargo test --test embeddings_rpc_e2e`

use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;

use axum::extract::State;
use axum::http::header::AUTHORIZATION;
use axum::routing::post;
use axum::{Json, Router};
use serde_json::{json, Value};
use tempfile::tempdir;

use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
use openhuman_core::core::jsonrpc::build_core_http_router;

// ── Auth / token setup ────────────────────────────────────────────────────────

const TEST_RPC_TOKEN: &str = "embeddings-e2e-test-token";
static E2E_AUTH_INIT: OnceLock<()> = OnceLock::new();

/// Serialises tests: env-var mutations (`HOME`, `OPENHUMAN_WORKSPACE`,
/// `OPENHUMAN_APP_ENV`) are process-global. The OnceLock+Mutex pattern mirrors
/// `json_rpc_e2e.rs` so tests don't race each other.
static EMBEDDINGS_E2E_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

fn embeddings_e2e_env_lock() -> std::sync::MutexGuard<'static, ()> {
    let mutex = EMBEDDINGS_E2E_ENV_LOCK.get_or_init(|| Mutex::new(()));
    match mutex.lock() {
        Ok(guard) => guard,
        Err(poisoned) => poisoned.into_inner(),
    }
}

fn ensure_test_rpc_auth() {
    E2E_AUTH_INIT.get_or_init(|| {
        // SAFETY: runs exactly once inside OnceLock before any concurrent env
        // reads occur. Rust 1.81+ requires unsafe for set_var in multi-threaded
        // contexts; the OnceLock guard limits the mutation to a single call.
        unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
        let token_dir = std::env::temp_dir().join("openhuman-embeddings-e2e-auth");
        init_rpc_token(&token_dir).expect("init rpc auth token for embeddings_rpc_e2e");
    });
}

// ── Env-var guard (RAII restore) ──────────────────────────────────────────────

struct EnvVarGuard {
    key: &'static str,
    old: Option<String>,
}

impl EnvVarGuard {
    fn set_to_path(key: &'static str, path: &Path) -> Self {
        let old = std::env::var(key).ok();
        unsafe { std::env::set_var(key, path.as_os_str()) };
        Self { key, old }
    }

    #[allow(dead_code)]
    fn set(key: &'static str, value: &str) -> Self {
        let old = std::env::var(key).ok();
        unsafe { std::env::set_var(key, value) };
        Self { key, old }
    }

    fn unset(key: &'static str) -> Self {
        let old = std::env::var(key).ok();
        unsafe { std::env::remove_var(key) };
        Self { key, old }
    }
}

impl Drop for EnvVarGuard {
    fn drop(&mut self) {
        match &self.old {
            Some(v) => unsafe { std::env::set_var(self.key, v) },
            None => unsafe { std::env::remove_var(self.key) },
        }
    }
}

// ── Minimal config writer ─────────────────────────────────────────────────────

fn write_min_config(config_dir: &Path) {
    std::fs::create_dir_all(config_dir).expect("mkdir for embeddings e2e config");
    let cfg = r#"api_url = "http://127.0.0.1:1"
default_model = "e2e-mock-model"
default_temperature = 0.7
chat_onboarding_completed = true

[secrets]
encrypt = false
"#;
    std::fs::write(config_dir.join("config.toml"), cfg).expect("write embeddings e2e config.toml");
}

// ── HTTP server helpers ───────────────────────────────────────────────────────

async fn serve_on_ephemeral() -> (
    SocketAddr,
    tokio::task::JoinHandle<Result<(), std::io::Error>>,
) {
    ensure_test_rpc_auth();
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind ephemeral port");
    let addr = listener.local_addr().expect("local_addr");
    let app = build_core_http_router(false);
    let handle = tokio::spawn(async move { axum::serve(listener, app).await });
    (addr, handle)
}

#[derive(Clone, Default)]
struct MockEmbeddingState {
    requests: Arc<Mutex<Vec<Value>>>,
    auth_headers: Arc<Mutex<Vec<Option<String>>>>,
}

async fn mock_openai_embeddings(
    State(state): State<MockEmbeddingState>,
    headers: axum::http::HeaderMap,
    Json(body): Json<Value>,
) -> Json<Value> {
    // Return exactly one embedding per input, like a real OpenAI-compatible
    // server — the client rejects a count mismatch (`openai embed count
    // mismatch`), and the save-time verification probe sends a single
    // `"connection test"` input while the real embed sends two. Captured before
    // `body` is moved into the request log below.
    let input_len = match body.get("input") {
        Some(Value::Array(items)) => items.len().max(1),
        _ => 1,
    };

    state
        .requests
        .lock()
        .expect("mock requests lock")
        .push(body);
    state
        .auth_headers
        .lock()
        .expect("mock auth headers lock")
        .push(
            headers
                .get(AUTHORIZATION)
                .and_then(|value| value.to_str().ok())
                .map(ToOwned::to_owned),
        );

    // Index 0 → [0.1,0.2,0.3], every other index → [0.4,0.5,0.6], so the
    // two-input embed assertion (`vectors[1][2] ≈ 0.6`) still holds.
    let data: Vec<Value> = (0..input_len)
        .map(|i| {
            let embedding = if i == 0 {
                [0.1, 0.2, 0.3]
            } else {
                [0.4, 0.5, 0.6]
            };
            json!({ "object": "embedding", "index": i, "embedding": embedding })
        })
        .collect();

    Json(json!({
        "object": "list",
        "data": data,
        "model": "mock-embedding-model"
    }))
}

async fn serve_mock_embeddings() -> (
    String,
    MockEmbeddingState,
    tokio::task::JoinHandle<Result<(), std::io::Error>>,
) {
    let state = MockEmbeddingState::default();
    let router = Router::new()
        .route("/v1/embeddings", post(mock_openai_embeddings))
        .with_state(state.clone());
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind mock embedding server");
    let addr = listener.local_addr().expect("mock embedding local_addr");
    let join = tokio::spawn(async move { axum::serve(listener, router).await });
    (format!("http://{addr}"), state, join)
}

async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(30))
        .build()
        .expect("build reqwest client");
    let body = json!({
        "jsonrpc": "2.0",
        "id": id,
        "method": method,
        "params": params
    });
    let url = format!("{}/rpc", rpc_base.trim_end_matches('/'));
    let resp = client
        .post(&url)
        .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
        .json(&body)
        .send()
        .await
        .unwrap_or_else(|e| panic!("POST {url}: {e}"));
    assert!(
        resp.status().is_success(),
        "HTTP error {} calling {}",
        resp.status(),
        method
    );
    resp.json::<Value>()
        .await
        .unwrap_or_else(|e| panic!("deserialize json for {method}: {e}"))
}

// ── Assertion helpers ─────────────────────────────────────────────────────────

fn assert_no_rpc_error<'a>(v: &'a Value, ctx: &str) -> &'a Value {
    if let Some(err) = v.get("error") {
        panic!("{ctx}: unexpected JSON-RPC error: {err}");
    }
    v.get("result")
        .unwrap_or_else(|| panic!("{ctx}: missing 'result' field in: {v}"))
}

// ── Test scaffolding: set up HOME + OPENHUMAN_WORKSPACE then start RPC server ─

/// Returns `(rpc_base, tempdir, guards)`. The `guards` tuple keeps all
/// `EnvVarGuard` values alive for the duration of the test.
async fn setup_embeddings_test() -> (
    String,
    tempfile::TempDir,
    (EnvVarGuard, EnvVarGuard, EnvVarGuard, EnvVarGuard),
    tokio::task::JoinHandle<Result<(), std::io::Error>>,
) {
    let tmp = tempdir().expect("tempdir");
    let home = tmp.path().to_path_buf();
    let openhuman_home = home.join(".openhuman");

    write_min_config(&openhuman_home);
    // Also write user-local config so that post-login config loads succeed.
    write_min_config(&openhuman_home.join("users").join("local"));

    let home_guard = EnvVarGuard::set_to_path("HOME", &home);
    let workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
    let backend_guard = EnvVarGuard::unset("BACKEND_URL");
    let vite_guard = EnvVarGuard::unset("VITE_BACKEND_URL");

    let (addr, join) = serve_on_ephemeral().await;
    let rpc_base = format!("http://{addr}");

    (
        rpc_base,
        tmp,
        (home_guard, workspace_guard, backend_guard, vite_guard),
        join,
    )
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[tokio::test(flavor = "multi_thread")]
async fn embeddings_get_settings_returns_catalog() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;

    let resp = post_json_rpc(&rpc_base, 1, "openhuman.embeddings_get_settings", json!({})).await;
    let result = assert_no_rpc_error(&resp, "embeddings_get_settings");

    // Unwrap one more layer if the controller wraps in {result: ...}
    let inner = result.get("result").unwrap_or(result);

    // Required top-level fields
    assert!(
        inner.get("provider").is_some(),
        "get_settings result missing 'provider': {inner}"
    );
    assert!(
        inner.get("model").is_some(),
        "get_settings result missing 'model': {inner}"
    );
    assert!(
        inner.get("dimensions").is_some(),
        "get_settings result missing 'dimensions': {inner}"
    );

    // Provider catalog
    let providers = inner
        .get("providers")
        .and_then(Value::as_array)
        .unwrap_or_else(|| panic!("get_settings result missing 'providers' array: {inner}"));
    assert!(
        providers.len() >= 5,
        "expected at least 5 providers in catalog, got {}: {providers:?}",
        providers.len()
    );

    // Each entry has the required fields
    for entry in providers.iter() {
        for field in &["slug", "label", "requires_api_key", "models"] {
            assert!(
                entry.get(field).is_some(),
                "provider entry missing '{field}': {entry}"
            );
        }
    }

    // Managed provider should not require an API key
    let managed = providers
        .iter()
        .find(|e| e.get("slug").and_then(Value::as_str) == Some("managed"))
        .expect("catalog must include 'managed' provider");
    assert_eq!(
        managed.get("requires_api_key").and_then(Value::as_bool),
        Some(false),
        "managed provider must not require an API key"
    );

    // Voyage provider requires a key
    let voyage = providers
        .iter()
        .find(|e| e.get("slug").and_then(Value::as_str) == Some("voyage"))
        .expect("catalog must include 'voyage' provider");
    assert_eq!(
        voyage.get("requires_api_key").and_then(Value::as_bool),
        Some(true),
        "voyage provider must require an API key"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn embeddings_update_settings_switches_provider() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;

    // Switch to "none" (noop) which has 0 dimensions — dimension change from
    // the default requires confirm_wipe. We pass it so the update goes through.
    let update = post_json_rpc(
        &rpc_base,
        2,
        "openhuman.embeddings_update_settings",
        json!({ "provider": "none", "confirm_wipe": true }),
    )
    .await;
    let update_result = assert_no_rpc_error(&update, "embeddings_update_settings");
    let inner = update_result.get("result").unwrap_or(update_result);

    assert_eq!(
        inner.get("provider").and_then(Value::as_str),
        Some("none"),
        "update_settings should return provider=none: {inner}"
    );

    // Subsequent get_settings should reflect the change
    let get = post_json_rpc(&rpc_base, 3, "openhuman.embeddings_get_settings", json!({})).await;
    let get_result = assert_no_rpc_error(&get, "embeddings_get_settings after update");
    let get_inner = get_result.get("result").unwrap_or(get_result);

    assert_eq!(
        get_inner.get("provider").and_then(Value::as_str),
        Some("none"),
        "get_settings after update should show provider=none: {get_inner}"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn embeddings_update_settings_dimension_change_requires_wipe() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;

    // First set provider to voyage (a provider that supports multiple dims)
    let _ = post_json_rpc(
        &rpc_base,
        10,
        "openhuman.embeddings_update_settings",
        json!({ "provider": "voyage", "model": "voyage-3-large", "dimensions": 1024, "confirm_wipe": true }),
    )
    .await;

    // Now try to change only dimensions without confirm_wipe — should get the
    // EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE sentinel in the result body.
    let resp = post_json_rpc(
        &rpc_base,
        11,
        "openhuman.embeddings_update_settings",
        json!({ "dimensions": 512 }),
    )
    .await;
    let result = assert_no_rpc_error(&resp, "update_settings no confirm_wipe");
    let inner = result.get("result").unwrap_or(result);

    assert_eq!(
        inner.get("error").and_then(Value::as_str),
        Some("EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE"),
        "expected EMBEDDINGS_DIMENSION_CHANGE_REQUIRES_WIPE sentinel in response body: {inner}"
    );
    assert!(
        inner.get("old_dimensions").is_some(),
        "response should include old_dimensions: {inner}"
    );
    assert!(
        inner.get("new_dimensions").is_some(),
        "response should include new_dimensions: {inner}"
    );

    // With confirm_wipe=true the change should succeed
    let confirmed = post_json_rpc(
        &rpc_base,
        12,
        "openhuman.embeddings_update_settings",
        json!({ "dimensions": 512, "confirm_wipe": true }),
    )
    .await;
    let confirmed_result = assert_no_rpc_error(&confirmed, "update_settings with confirm_wipe");
    let confirmed_inner = confirmed_result.get("result").unwrap_or(confirmed_result);

    // The confirmed update should NOT carry the error sentinel
    assert!(
        confirmed_inner.get("error").is_none(),
        "confirmed update must not return an error sentinel: {confirmed_inner}"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn embeddings_set_and_clear_api_key() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;

    // Store a key for voyage
    let set_resp = post_json_rpc(
        &rpc_base,
        20,
        "openhuman.embeddings_set_api_key",
        json!({ "provider": "voyage", "api_key": "voy-test-1234" }),
    )
    .await;
    let set_result = assert_no_rpc_error(&set_resp, "embeddings_set_api_key");
    let set_inner = set_result.get("result").unwrap_or(set_result);
    assert_eq!(
        set_inner.get("stored").and_then(Value::as_bool),
        Some(true),
        "set_api_key should report stored=true: {set_inner}"
    );

    // get_settings should now show has_api_key=true for voyage
    let get_resp = post_json_rpc(
        &rpc_base,
        21,
        "openhuman.embeddings_get_settings",
        json!({}),
    )
    .await;
    let get_result = assert_no_rpc_error(&get_resp, "get_settings after set_api_key");
    let get_inner = get_result.get("result").unwrap_or(get_result);
    let providers = get_inner
        .get("providers")
        .and_then(Value::as_array)
        .expect("providers array missing");
    let voyage = providers
        .iter()
        .find(|e| e.get("slug").and_then(Value::as_str) == Some("voyage"))
        .expect("voyage provider missing from catalog");
    assert_eq!(
        voyage.get("has_api_key").and_then(Value::as_bool),
        Some(true),
        "voyage should report has_api_key=true after storing key: {voyage}"
    );

    // Clear the key
    let clear_resp = post_json_rpc(
        &rpc_base,
        22,
        "openhuman.embeddings_clear_api_key",
        json!({ "provider": "voyage" }),
    )
    .await;
    let clear_result = assert_no_rpc_error(&clear_resp, "embeddings_clear_api_key");
    let clear_inner = clear_result.get("result").unwrap_or(clear_result);
    assert_eq!(
        clear_inner.get("cleared").and_then(Value::as_bool),
        Some(true),
        "clear_api_key should report cleared=true: {clear_inner}"
    );

    // has_api_key should be false again
    let get2_resp = post_json_rpc(
        &rpc_base,
        23,
        "openhuman.embeddings_get_settings",
        json!({}),
    )
    .await;
    let get2_result = assert_no_rpc_error(&get2_resp, "get_settings after clear_api_key");
    let get2_inner = get2_result.get("result").unwrap_or(get2_result);
    let providers2 = get2_inner
        .get("providers")
        .and_then(Value::as_array)
        .expect("providers array missing after clear");
    let voyage2 = providers2
        .iter()
        .find(|e| e.get("slug").and_then(Value::as_str) == Some("voyage"))
        .expect("voyage missing after clear");
    assert_eq!(
        voyage2.get("has_api_key").and_then(Value::as_bool),
        Some(false),
        "voyage should report has_api_key=false after clearing key: {voyage2}"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn embeddings_test_connection_with_none_provider() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;

    // Switch to "none" so test_connection uses the noop provider (no network).
    let _ = post_json_rpc(
        &rpc_base,
        30,
        "openhuman.embeddings_update_settings",
        json!({ "provider": "none", "confirm_wipe": true }),
    )
    .await;

    let resp = post_json_rpc(
        &rpc_base,
        31,
        "openhuman.embeddings_test_connection",
        json!({}),
    )
    .await;
    let result = assert_no_rpc_error(&resp, "embeddings_test_connection none");
    let inner = result.get("result").unwrap_or(result);

    // The noop provider's embed() returns an empty vec, which causes the
    // test_connection result to show success=false with an "Empty embedding
    // result" error — that's acceptable. What we assert is that the call
    // completes without a JSON-RPC level error and returns a structured body.
    assert!(
        inner.get("success").is_some(),
        "test_connection should return a 'success' field: {inner}"
    );
    assert!(
        inner.get("provider").is_some(),
        "test_connection should return a 'provider' field: {inner}"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn embeddings_embed_with_none_returns_empty_vectors() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;

    // Switch to noop so embed() doesn't require network.
    let _ = post_json_rpc(
        &rpc_base,
        40,
        "openhuman.embeddings_update_settings",
        json!({ "provider": "none", "confirm_wipe": true }),
    )
    .await;

    let resp = post_json_rpc(
        &rpc_base,
        41,
        "openhuman.embeddings_embed",
        json!({ "inputs": ["hello", "world"] }),
    )
    .await;
    let result = assert_no_rpc_error(&resp, "embeddings_embed none");
    let inner = result.get("result").unwrap_or(result);

    // NoopEmbedding.embed() returns an empty vec — count and dimensions should both be 0.
    assert_eq!(
        inner.get("count").and_then(Value::as_u64),
        Some(0),
        "noop provider should return count=0: {inner}"
    );
    assert_eq!(
        inner.get("dimensions").and_then(Value::as_u64),
        Some(0),
        "noop provider should return dimensions=0: {inner}"
    );
    let vectors = inner
        .get("vectors")
        .and_then(Value::as_array)
        .expect("embed result must include 'vectors' array: {inner}");
    assert!(
        vectors.is_empty(),
        "noop provider must return empty vectors array: {vectors:?}"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn embeddings_embed_with_custom_openai_endpoint_round_trips_vectors_and_api_key() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;
    let (mock_base, mock_state, mock_join) = serve_mock_embeddings().await;

    let set_key = post_json_rpc(
        &rpc_base,
        45,
        "openhuman.embeddings_set_api_key",
        json!({ "provider": "custom", "api_key": "custom-embedding-key" }),
    )
    .await;
    assert_no_rpc_error(&set_key, "embeddings_set_api_key custom");

    let update = post_json_rpc(
        &rpc_base,
        46,
        "openhuman.embeddings_update_settings",
        json!({
            "provider": "custom",
            "custom_endpoint": mock_base,
            "model": "mock-embedding-model",
            "dimensions": 3,
            "confirm_wipe": true
        }),
    )
    .await;
    let update_result = assert_no_rpc_error(&update, "embeddings_update_settings custom");
    let update_inner = update_result.get("result").unwrap_or(update_result);
    let expected_provider = format!("custom:{mock_base}");
    assert_eq!(
        update_inner.get("provider").and_then(Value::as_str),
        Some(expected_provider.as_str())
    );

    let embed = post_json_rpc(
        &rpc_base,
        47,
        "openhuman.embeddings_embed",
        json!({ "inputs": ["first custom text", "second custom text"] }),
    )
    .await;
    let embed_result = assert_no_rpc_error(&embed, "embeddings_embed custom");
    let inner = embed_result.get("result").unwrap_or(embed_result);

    assert_eq!(
        inner.get("provider").and_then(Value::as_str),
        Some("custom")
    );
    assert_eq!(
        inner.get("model").and_then(Value::as_str),
        Some("mock-embedding-model")
    );
    assert_eq!(inner.get("count").and_then(Value::as_u64), Some(2));
    assert_eq!(inner.get("dimensions").and_then(Value::as_u64), Some(3));
    let last_component = inner
        .pointer("/vectors/1/2")
        .and_then(Value::as_f64)
        .expect("second vector third component");
    assert!(
        (last_component - 0.6).abs() < 0.000_001,
        "expected f32-roundtripped component near 0.6, got {last_component}"
    );

    let requests = mock_state.requests.lock().expect("mock requests lock");
    // Two hits: (0) the save-time connectivity probe update_settings now runs
    // against custom endpoints (TAURI-RUST-5JR prevention), (1) the real embed.
    assert_eq!(
        requests.len(),
        2,
        "expected one save-time probe + one embed call"
    );
    assert_eq!(
        requests[0].pointer("/input/0").and_then(Value::as_str),
        Some("connection test"),
        "first call must be the save-time validation probe"
    );
    assert_eq!(
        requests[1].get("model").and_then(Value::as_str),
        Some("mock-embedding-model")
    );
    assert_eq!(
        requests[1].pointer("/input/0").and_then(Value::as_str),
        Some("first custom text")
    );
    drop(requests);

    let auth_headers = mock_state
        .auth_headers
        .lock()
        .expect("mock auth headers lock");
    assert_eq!(
        auth_headers.first().and_then(|value| value.as_deref()),
        Some("Bearer custom-embedding-key")
    );

    mock_join.abort();
}

/// A mock "OpenAI-compatible" host that has NO embeddings route — every POST to
/// `/v1/embeddings` 404s, exactly like a chat-only provider (DeepSeek) does.
async fn serve_mock_embeddings_no_api(
) -> (String, tokio::task::JoinHandle<Result<(), std::io::Error>>) {
    async fn not_found() -> (axum::http::StatusCode, &'static str) {
        (axum::http::StatusCode::NOT_FOUND, "Not Found")
    }
    let router = Router::new().route("/v1/embeddings", post(not_found));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind no-api mock server");
    let addr = listener.local_addr().expect("no-api mock local_addr");
    let join = tokio::spawn(async move { axum::serve(listener, router).await });
    (format!("http://{addr}"), join)
}

/// TAURI-RUST-5JR prevention: `update_settings` must probe a custom endpoint
/// and REFUSE to persist one that has no embeddings API (404), returning
/// `EMBEDDINGS_ENDPOINT_NO_API` and leaving the stored provider unchanged — so
/// the 404-on-every-re-embed Sentry flood can never be configured.
#[tokio::test(flavor = "multi_thread")]
async fn embeddings_update_settings_rejects_endpoint_with_no_embeddings_api() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;
    let (mock_base, mock_join) = serve_mock_embeddings_no_api().await;

    // Snapshot the provider before the rejected save.
    let before = post_json_rpc(
        &rpc_base,
        80,
        "openhuman.embeddings_get_settings",
        json!({}),
    )
    .await;
    let before_result = assert_no_rpc_error(&before, "get_settings before");
    let before_inner = before_result.get("result").unwrap_or(before_result);
    let before_provider = before_inner
        .get("provider")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();

    let update = post_json_rpc(
        &rpc_base,
        81,
        "openhuman.embeddings_update_settings",
        json!({
            "provider": "custom",
            "custom_endpoint": mock_base,
            "model": "mock-embedding-model",
            "dimensions": 3,
            "confirm_wipe": true
        }),
    )
    .await;
    let update_result = assert_no_rpc_error(&update, "update_settings no-api endpoint");
    let update_inner = update_result.get("result").unwrap_or(update_result);
    assert_eq!(
        update_inner.get("error").and_then(Value::as_str),
        Some("EMBEDDINGS_ENDPOINT_NO_API"),
        "a no-embeddings endpoint must be rejected, not saved: {update_inner}"
    );

    // The stored provider must be unchanged — nothing was persisted.
    let after = post_json_rpc(
        &rpc_base,
        82,
        "openhuman.embeddings_get_settings",
        json!({}),
    )
    .await;
    let after_result = assert_no_rpc_error(&after, "get_settings after");
    let after_inner = after_result.get("result").unwrap_or(after_result);
    assert_eq!(
        after_inner.get("provider").and_then(Value::as_str),
        Some(before_provider.as_str()),
        "provider must be unchanged after a rejected save: {after_inner}"
    );

    mock_join.abort();
}

#[tokio::test(flavor = "multi_thread")]
async fn legacy_alias_inference_embed_resolves() {
    let _lock = embeddings_e2e_env_lock();
    let (rpc_base, _tmp, _guards, _join) = setup_embeddings_test().await;

    // Set provider to none so the embed call itself doesn't fail on missing keys.
    let _ = post_json_rpc(
        &rpc_base,
        50,
        "openhuman.embeddings_update_settings",
        json!({ "provider": "none", "confirm_wipe": true }),
    )
    .await;

    // Call via the legacy alias — must NOT return an "unknown method" JSON-RPC
    // error; the alias table must rewrite it to openhuman.embeddings_embed.
    let resp = post_json_rpc(
        &rpc_base,
        51,
        "openhuman.inference_embed",
        json!({ "inputs": [] }),
    )
    .await;

    // If the alias resolution failed we'd get a JSON-RPC error with code -32601.
    if let Some(err) = resp.get("error") {
        let code = err.get("code").and_then(Value::as_i64).unwrap_or(0);
        assert_ne!(
            code, -32601,
            "legacy alias openhuman.inference_embed resolved to 'method not found' — alias table may be broken: {err}"
        );
    }

    // The resolved call should succeed (no JSON-RPC error) and return a result.
    let result = assert_no_rpc_error(&resp, "legacy inference_embed alias");
    let inner = result.get("result").unwrap_or(result);
    assert!(
        inner.get("vectors").is_some() || inner.get("count").is_some(),
        "legacy alias should resolve to embeddings_embed and return vector data: {inner}"
    );
}
