//! JSON-RPC controller surface for inference operations.

use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::config::Config;
use crate::openhuman::inference::local as local_runtime;
use crate::openhuman::inference::local::ops::ReactionDecision;
use crate::openhuman::inference::provider as providers;
use crate::openhuman::inference::{device, presets, sentiment, SentimentResult};
use crate::openhuman::inference::{LocalAiEmbeddingResult, LocalAiStatus};
use crate::rpc::RpcOutcome;
use serde_json::{json, Value};
use tracing::{debug, error, warn};

const LOG_PREFIX: &str = "[inference::ops]";

/// User picked a provider id (slug) that isn't registered in the cloud
/// provider list — e.g. selecting `"ollama"` as a cloud provider when it's
/// actually a local runtime. Matches the literal phrase emitted at
/// `src/openhuman/inference/provider/ops.rs:54`
/// (`"no cloud provider with id or slug '{}' found"`).
///
/// Used by [`inference_list_models`] to demote this user-config case to
/// `warn!` so it stops escalating to Sentry (TAURI-RUST-X, ~5740 events).
/// The matcher is anchored on the exact phrase so unrelated sibling
/// failures (TAURI-RUST-12 JSON parse, TAURI-RUST-2W reqwest builder,
/// TAURI-RUST-JP local ollama_admin transport) still surface as real
/// errors.
fn is_unknown_provider_user_config(err: &str) -> bool {
    err.contains("no cloud provider with id or slug")
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct InferenceTestProviderModelResult {
    pub reply: String,
}

fn expected_test_provider_model_error_kind(
    err: &str,
) -> Option<crate::core::observability::ExpectedErrorKind> {
    crate::core::observability::expected_error_kind(err)
}

pub async fn inference_status(config: &Config) -> Result<RpcOutcome<LocalAiStatus>, String> {
    debug!("{LOG_PREFIX} status:start");
    let result = local_runtime::rpc::local_ai_status(config).await;
    match &result {
        Ok(outcome) => debug!(state = %outcome.value.state, "{LOG_PREFIX} status:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} status:error"),
    }
    result
}

pub async fn inference_summarize(
    config: &Config,
    text: &str,
    max_tokens: Option<u32>,
) -> Result<RpcOutcome<String>, String> {
    debug!(
        text_len = text.len(),
        ?max_tokens,
        "{LOG_PREFIX} summarize:start"
    );
    let result = local_runtime::rpc::local_ai_summarize(config, text, max_tokens).await;
    match &result {
        Ok(outcome) => debug!(
            output_len = outcome.value.len(),
            "{LOG_PREFIX} summarize:ok"
        ),
        Err(err) => error!(error = %err, "{LOG_PREFIX} summarize:error"),
    }
    result
}

pub async fn inference_prompt(
    config: &Config,
    prompt: &str,
    max_tokens: Option<u32>,
    no_think: Option<bool>,
) -> Result<RpcOutcome<String>, String> {
    debug!(
        prompt_len = prompt.len(),
        ?max_tokens,
        ?no_think,
        "{LOG_PREFIX} prompt:start"
    );
    let result = local_runtime::rpc::local_ai_prompt(config, prompt, max_tokens, no_think).await;
    match &result {
        Ok(outcome) => debug!(output_len = outcome.value.len(), "{LOG_PREFIX} prompt:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} prompt:error"),
    }
    result
}

pub async fn inference_vision_prompt(
    config: &Config,
    prompt: &str,
    image_refs: &[String],
    max_tokens: Option<u32>,
) -> Result<RpcOutcome<String>, String> {
    debug!(
        prompt_len = prompt.len(),
        image_count = image_refs.len(),
        ?max_tokens,
        "{LOG_PREFIX} vision_prompt:start"
    );
    let result =
        local_runtime::rpc::local_ai_vision_prompt(config, prompt, image_refs, max_tokens).await;
    match &result {
        Ok(outcome) => debug!(
            output_len = outcome.value.len(),
            "{LOG_PREFIX} vision_prompt:ok"
        ),
        Err(err) => error!(error = %err, "{LOG_PREFIX} vision_prompt:error"),
    }
    result
}

pub async fn inference_embed(
    config: &Config,
    inputs: &[String],
) -> Result<RpcOutcome<LocalAiEmbeddingResult>, String> {
    debug!(input_count = inputs.len(), "{LOG_PREFIX} embed:start");
    let result = local_runtime::rpc::local_ai_embed(config, inputs).await;
    match &result {
        Ok(outcome) => debug!(
            vector_count = outcome.value.vectors.len(),
            dimensions = outcome.value.dimensions,
            "{LOG_PREFIX} embed:ok"
        ),
        Err(err) => error!(error = %err, "{LOG_PREFIX} embed:error"),
    }
    result
}

pub async fn inference_test_provider_model(
    config: &Config,
    workload: &str,
    provider: &str,
    prompt: &str,
) -> Result<RpcOutcome<InferenceTestProviderModelResult>, String> {
    debug!(
        workload,
        provider,
        prompt_len = prompt.len(),
        "{LOG_PREFIX} test_provider_model:start"
    );
    let result =
        if provider.trim().starts_with("lmstudio:") || provider.trim().starts_with("ollama:") {
            log::debug!("{LOG_PREFIX} test_provider_model: routing to local provider={provider}");
            let (chat_provider, model) =
            crate::openhuman::inference::provider::factory::create_local_chat_provider_from_string(
                provider, config,
            )
            .map_err(|e| e.to_string())?;
            log::debug!("{LOG_PREFIX} test_provider_model: invoking local model={model}");
            chat_provider
                .simple_chat(prompt, &model, config.default_temperature)
                .await
                .map_err(|e| e.to_string())
                .map(|reply| {
                    RpcOutcome::single_log(
                        InferenceTestProviderModelResult { reply },
                        "provider model test completed",
                    )
                })
        } else {
            let (chat_provider, model) =
                crate::openhuman::inference::provider::factory::create_chat_provider_from_string(
                    workload, provider, config,
                )
                .map_err(|e| e.to_string())?;
            chat_provider
                .simple_chat(prompt, &model, config.default_temperature)
                .await
                .map_err(|e| e.to_string())
                .map(|reply| {
                    RpcOutcome::single_log(
                        InferenceTestProviderModelResult { reply },
                        "provider model test completed",
                    )
                })
        };
    match &result {
        Ok(outcome) => debug!(
            output_len = outcome.value.reply.len(),
            "{LOG_PREFIX} test_provider_model:ok"
        ),
        Err(err) => {
            if let Some(kind) = expected_test_provider_model_error_kind(err) {
                warn!(
                    workload,
                    provider,
                    expected_error_kind = ?kind,
                    error = %err,
                    "{LOG_PREFIX} test_provider_model:expected_error"
                );
            } else {
                error!(
                    workload,
                    provider,
                    error = %err,
                    "{LOG_PREFIX} test_provider_model:error"
                );
            }
        }
    }
    result
}

pub async fn inference_should_react(
    config: &Config,
    message: &str,
    channel_type: &str,
) -> Result<RpcOutcome<ReactionDecision>, String> {
    debug!(
        message_len = message.len(),
        channel_type, "{LOG_PREFIX} should_react:start"
    );
    let result = local_runtime::rpc::local_ai_should_react(config, message, channel_type).await;
    match &result {
        Ok(outcome) => debug!(
            should_react = outcome.value.should_react,
            "{LOG_PREFIX} should_react:ok"
        ),
        Err(err) => error!(error = %err, "{LOG_PREFIX} should_react:error"),
    }
    result
}

pub async fn inference_analyze_sentiment(
    config: &Config,
    message: &str,
) -> Result<RpcOutcome<SentimentResult>, String> {
    debug!(
        message_len = message.len(),
        "{LOG_PREFIX} analyze_sentiment:start"
    );
    let result = sentiment::local_ai_analyze_sentiment(config, message).await;
    match &result {
        Ok(outcome) => {
            debug!(valence = %outcome.value.valence, "{LOG_PREFIX} analyze_sentiment:ok")
        }
        Err(err) => error!(error = %err, "{LOG_PREFIX} analyze_sentiment:error"),
    }
    result
}

pub async fn inference_get_client_config() -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} get_client_config:start");
    let result = config_rpc::load_and_get_client_config_snapshot().await;
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} get_client_config:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} get_client_config:error"),
    }
    result
}

pub async fn inference_update_model_settings(
    update: config_rpc::ModelSettingsPatch,
) -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} update_model_settings:start");
    let result = config_rpc::load_and_apply_model_settings(update).await;
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} update_model_settings:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} update_model_settings:error"),
    }
    result
}

pub async fn inference_update_local_settings(
    update: config_rpc::LocalAiSettingsPatch,
) -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} update_local_settings:start");
    let result = config_rpc::load_and_apply_local_ai_settings(update).await;
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} update_local_settings:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} update_local_settings:error"),
    }
    result
}

pub async fn inference_list_models(provider_id: &str) -> Result<RpcOutcome<Value>, String> {
    debug!(provider_id, "{LOG_PREFIX} list_models:start");
    let result = providers::ops::list_configured_models(provider_id).await;
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} list_models:ok"),
        Err(err) => {
            if is_unknown_provider_user_config(err) {
                // User selected a provider id that isn't a registered
                // cloud provider (e.g. picking "ollama", a local runtime).
                // Demote to `warn!` so it stays in local logs but doesn't
                // escalate to Sentry. Targets TAURI-RUST-X (~5740 events).
                warn!(
                    provider_id,
                    error = %err,
                    "{LOG_PREFIX} list_models:unknown-provider (user-config)"
                );
            } else if let Some(kind) = crate::core::observability::expected_error_kind(err) {
                // Classify at the TYPED SOURCE — run the raw provider error
                // through the central classifier BEFORE the
                // `[inference::ops] list_models:error: …` prefix is applied,
                // then `warn!` so the Sentry tracing layer records at most a
                // breadcrumb instead of a hard error event.
                //
                // TAURI-RUST-8X3: a user pointed a custom OpenAI-compatible
                // provider at a base URL with no `/models` route, so the
                // probe returns `provider returned 404: 404 page not found`.
                // That is a preventable user-state condition (wrong base URL;
                // the dropdown already surfaces an actionable hint inline) —
                // not a code bug. The 404 arm of
                // `is_provider_user_state_message` matched the *raw* error
                // string fine, but the previous `error!` path captured the
                // PREFIXED log line, so the demotion never reached Sentry's
                // classifier. Classifying the raw `err` here removes that
                // dependency on the log-string shape entirely; any
                // `ExpectedErrorKind` the central classifier recognizes is
                // demoted at the source.
                warn!(
                    provider_id,
                    error = %err,
                    expected_kind = ?kind,
                    "{LOG_PREFIX} list_models:expected (user-config): {err}"
                );
            } else {
                // Real error — embed `{err}` in the format string so
                // Sentry's event title carries the actionable cause
                // instead of the opaque `list_models:error` shape that
                // made TAURI-RUST-X untriageable.
                error!(
                    provider_id,
                    error = %err,
                    "{LOG_PREFIX} list_models:error: {err}"
                );
            }
        }
    }
    result
}

pub async fn inference_device_profile() -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} device_profile:start");
    let profile = device::detect_device_profile();
    let result = Ok(RpcOutcome::single_log(
        serde_json::to_value(profile).map_err(|e| format!("serialize: {e}"))?,
        "inference device profile fetched",
    ));
    debug!("{LOG_PREFIX} device_profile:ok");
    result
}

/// Snapshot of BYO provider auth failures (invalid / revoked key, 401 / 403)
/// recorded this process. Backs the AI-settings provider-error notice so a
/// key that breaks at runtime — most often in a silent background loop like
/// memory summarization (TAURI-RUST-4RC) — is surfaced inline next to the key
/// editor, not only in the notification center. Cleared when the user updates
/// or removes the offending key.
pub async fn inference_provider_auth_errors() -> Result<RpcOutcome<Value>, String> {
    let errors = providers::auth_error_registry::snapshot();
    debug!(count = errors.len(), "{LOG_PREFIX} provider_auth_errors:ok");
    Ok(RpcOutcome::single_log(
        json!({ "errors": errors }),
        "inference provider auth errors fetched",
    ))
}

pub async fn inference_presets() -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} presets:start");
    let config = config_rpc::load_config_with_timeout().await?;
    let device = device::detect_device_profile();
    let recommended = presets::recommend_tier(&device);
    let current = presets::current_tier_from_config(&config.local_ai);
    let selected_tier = config.local_ai.selected_tier.as_ref().and_then(|value| {
        let normalized = value.trim().to_ascii_lowercase();
        presets::ModelTier::from_str_opt(&normalized)
            .map(|tier| tier.as_str().to_string())
            .or_else(|| (!normalized.is_empty()).then_some(normalized))
    });
    let presets = presets::mvp_presets();
    let recommend_disabled = presets::should_default_to_cloud_fallback(&device);
    let result = Ok(RpcOutcome::single_log(
        json!({
            "presets": presets,
            "recommended_tier": recommended,
            "current_tier": current,
            "selected_tier": selected_tier,
            "device": device,
            "recommend_disabled": recommend_disabled,
            "local_ai_enabled": config.local_ai.runtime_enabled,
        }),
        "inference presets fetched",
    ));
    debug!("{LOG_PREFIX} presets:ok");
    result
}

pub async fn inference_apply_preset(tier: &str) -> Result<RpcOutcome<Value>, String> {
    let tier_str = tier.trim().to_ascii_lowercase();
    debug!(tier = %tier_str, "{LOG_PREFIX} apply_preset:start");

    if tier_str == "disabled" {
        let mut config = config_rpc::load_config_with_timeout().await?;
        config.local_ai.runtime_enabled = false;
        config.local_ai.selected_tier = Some("disabled".to_string());
        config.local_ai.opt_in_confirmed = false;
        config
            .save()
            .await
            .map_err(|e| format!("save config: {e}"))?;
        debug!("{LOG_PREFIX} apply_preset:disabled");
        return Ok(RpcOutcome::single_log(
            json!({
                "applied_tier": "disabled",
                "local_ai_enabled": false,
            }),
            "inference preset applied",
        ));
    }

    let tier = presets::ModelTier::from_str_opt(&tier_str).ok_or_else(|| {
        format!(
            "invalid tier '{}': expected one of disabled or ram_2_4gb",
            tier_str
        )
    })?;

    if tier == presets::ModelTier::Custom {
        return Err("cannot apply 'custom' tier; set model IDs directly".to_string());
    }
    if !tier.is_mvp_allowed() {
        return Err(format!(
            "tier '{}' is not available in this build; only the 1B local model preset is supported",
            tier_str
        ));
    }

    let mut config = config_rpc::load_config_with_timeout().await?;
    config.local_ai.runtime_enabled = true;
    config.local_ai.opt_in_confirmed = true;
    presets::apply_preset_to_config(&mut config.local_ai, tier);
    config
        .save()
        .await
        .map_err(|e| format!("save config: {e}"))?;

    debug!(tier = %tier_str, "{LOG_PREFIX} apply_preset:ok");
    Ok(RpcOutcome::single_log(
        json!({
            "applied_tier": tier,
            "chat_model_id": config.local_ai.chat_model_id,
            "vision_model_id": config.local_ai.vision_model_id,
            "embedding_model_id": config.local_ai.embedding_model_id,
            "quantization": config.local_ai.quantization,
            "vision_mode": presets::vision_mode_for_config(&config.local_ai),
            "local_ai_enabled": true,
        }),
        "inference preset applied",
    ))
}

pub async fn inference_openai_oauth_start(config: &Config) -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} openai_oauth_start:start");
    let result =
        crate::openhuman::inference::openai_oauth::start_openai_oauth(config).map(|start| {
            RpcOutcome::single_log(
                json!({
                    "authUrl": start.auth_url,
                    "state": start.state,
                    "redirectUri": start.redirect_uri,
                }),
                "openai oauth authorize url ready",
            )
        });
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} openai_oauth_start:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_start:error"),
    }
    result
}

pub async fn inference_openai_oauth_complete(
    config: &Config,
    callback_url: &str,
) -> Result<RpcOutcome<Value>, String> {
    debug!(
        callback_len = callback_url.len(),
        "{LOG_PREFIX} openai_oauth_complete:start"
    );
    let result =
        crate::openhuman::inference::openai_oauth::complete_openai_oauth(config, callback_url)
            .await
            .map(|payload| RpcOutcome::single_log(payload, "openai oauth connected"));
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} openai_oauth_complete:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_complete:error"),
    }
    result
}

pub async fn inference_openai_oauth_import_codex_cli(
    config: &Config,
) -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} openai_oauth_import_codex_cli:start");
    let result =
        crate::openhuman::inference::openai_oauth::import_openai_oauth_from_codex_cli(config)
            .map(|payload| RpcOutcome::single_log(payload, "openai oauth imported from codex cli"));
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} openai_oauth_import_codex_cli:ok"),
        // Most failures here are expected user-state (no `~/.codex/auth.json`,
        // user never ran `codex login`, stale/empty file) — the UI already
        // surfaces the actionable error, so route through the observability
        // classifier to keep that flood out of Sentry (TAURI-RUST-83A) while a
        // genuine keyring/persist defect still falls through to a real event.
        Err(err) => crate::core::observability::report_error_or_expected(
            err,
            "inference",
            "openai_oauth_import_codex_cli",
            &[],
        ),
    }
    result
}

pub async fn inference_openai_oauth_status(config: &Config) -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} openai_oauth_status:start");
    let result =
        crate::openhuman::inference::openai_oauth::openai_oauth_status(config).map(|status| {
            RpcOutcome::single_log(
                json!({
                    "connected": status.connected,
                    "profileId": status.profile_id,
                    "expiresAt": status.expires_at,
                    "authMethod": status.auth_method,
                }),
                "openai oauth status",
            )
        });
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} openai_oauth_status:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_status:error"),
    }
    result
}

pub async fn inference_openai_oauth_disconnect(
    config: &Config,
) -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} openai_oauth_disconnect:start");
    let result = crate::openhuman::inference::openai_oauth::disconnect_openai_oauth(config)
        .map(|payload| RpcOutcome::single_log(payload, "openai oauth disconnected"));
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} openai_oauth_disconnect:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_disconnect:error"),
    }
    result
}

pub async fn inference_diagnostics(config: &Config) -> Result<RpcOutcome<Value>, String> {
    debug!("{LOG_PREFIX} diagnostics:start");
    let service = local_runtime::global(config);
    // Return the diagnostics payload directly (no `{result, logs}` wrap) so
    // callers (UI + json_rpc_e2e tests) can read `provider`, `lm_studio_running`,
    // etc. straight off the response — mirrors the legacy
    // `local_ai_diagnostics` shape that the test asserts against.
    let result = service
        .diagnostics(config)
        .await
        .map(|value| RpcOutcome::new(value, Vec::new()));
    match &result {
        Ok(_) => debug!("{LOG_PREFIX} diagnostics:ok"),
        Err(err) => error!(error = %err, "{LOG_PREFIX} diagnostics:error"),
    }
    result
}

#[cfg(test)]
#[path = "ops_tests.rs"]
mod tests;
