use super::*;
use axum::{routing::post, Json, Router};
use serde_json::json;

async fn spawn_mock(app: Router) -> String {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    format!("http://127.0.0.1:{}", addr.port())
}

fn enabled_config() -> Config {
    let mut config = Config::default();
    config.local_ai.runtime_enabled = true;
    config
}

fn lm_studio_config(base: &str) -> Config {
    let mut config = enabled_config();
    config.local_ai.provider = "lm_studio".to_string();
    config.local_ai.base_url = Some(format!("{base}/v1"));
    config.local_ai.model_id = "local-model".to_string();
    config.local_ai.chat_model_id = "local-model".to_string();
    config.local_ai.opt_in_confirmed = true;
    config
}

/// Build a LocalAiService pre-seeded to `ready` so inference calls skip
/// `bootstrap()` and hit the HTTP path directly.
fn ready_service(config: &Config) -> LocalAiService {
    let service = LocalAiService::new(config);
    {
        let mut guard = service.status.lock();
        guard.state = "ready".to_string();
    }
    service
}

#[tokio::test]
async fn inference_hits_ollama_generate_and_returns_response() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let app = Router::new().route(
        "/api/generate",
        post(|Json(_body): Json<serde_json::Value>| async move {
            Json(json!({
                "model": "test",
                "response": "hello from mock",
                "done": true,
                "total_duration": 1_000_000u64,
                "prompt_eval_count": 5,
                "prompt_eval_duration": 100_000u64,
                "eval_count": 3,
                "eval_duration": 500_000u64
            }))
        }),
    );
    let base = spawn_mock(app).await;
    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
    }

    let config = enabled_config();
    let service = ready_service(&config);
    let reply = service
        .prompt(&config, "hi", Some(16), true)
        .await
        .expect("ollama prompt");
    assert_eq!(reply, "hello from mock");

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }
}

#[tokio::test]
async fn inference_errors_on_non_success_status() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let app = Router::new().route(
        "/api/generate",
        post(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "boom") }),
    );
    let base = spawn_mock(app).await;
    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
    }

    let config = enabled_config();
    let service = ready_service(&config);
    let err = service.prompt(&config, "hi", None, true).await.unwrap_err();
    assert!(err.contains("500"));

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }
}

#[tokio::test]
async fn inference_connection_failure_mentions_external_ollama_runtime() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", "http://127.0.0.1:1");
    }

    let config = enabled_config();
    let service = ready_service(&config);
    let err = service.prompt(&config, "hi", None, true).await.unwrap_err();

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }

    assert!(
        err.contains("external Ollama endpoint"),
        "unexpected error: {err}"
    );
    assert!(err.contains("already running"), "unexpected error: {err}");
}

#[tokio::test]
async fn inference_errors_on_empty_response_when_allow_empty_false() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let app = Router::new().route(
        "/api/generate",
        post(|| async {
            Json(json!({
                "model": "test",
                "response": "   ",
                "done": true
            }))
        }),
    );
    let base = spawn_mock(app).await;
    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
    }

    let config = enabled_config();
    let service = ready_service(&config);
    // `inference()` is the lower-level entry that hard-codes
    // allow_empty=false, so a whitespace-only mock response must
    // surface as the "empty content" error.
    let res = service.inference(&config, "", "hi", None, false).await;

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }

    let err = res.expect_err("whitespace response must be rejected when allow_empty=false");
    assert!(
        err.contains("empty"),
        "expected an empty-content error, got: {err}"
    );
}

#[tokio::test]
async fn lm_studio_prompt_hits_openai_chat_completions() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let app = Router::new().route(
        "/v1/chat/completions",
        post(|Json(body): Json<serde_json::Value>| async move {
            assert_eq!(body["model"], "local-model");
            assert_eq!(body["stream"], false);
            assert_eq!(body["max_tokens"], 16);
            assert_eq!(body["messages"][0]["role"], "system");
            assert_eq!(body["messages"][1]["role"], "user");
            Json(json!({
                "id": "chatcmpl-test",
                "object": "chat.completion",
                "choices": [{
                    "index": 0,
                    "message": { "role": "assistant", "content": "hello from lm studio" },
                    "finish_reason": "stop"
                }],
                "usage": { "prompt_tokens": 7, "completion_tokens": 4, "total_tokens": 11 }
            }))
        }),
    );
    let base = spawn_mock(app).await;
    let config = lm_studio_config(&base);
    let service = ready_service(&config);

    let reply = service
        .prompt(&config, "hi", Some(16), true)
        .await
        .expect("lm studio prompt");

    assert_eq!(reply, "hello from lm studio");
    let status = service.status();
    assert_eq!(status.provider, "lm_studio");
    assert_eq!(status.state, "ready");
}

#[tokio::test]
async fn lm_studio_prompt_errors_on_non_success_status() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let app = Router::new().route(
        "/v1/chat/completions",
        post(|| async { (axum::http::StatusCode::BAD_GATEWAY, "not ready") }),
    );
    let base = spawn_mock(app).await;
    let config = lm_studio_config(&base);
    let service = ready_service(&config);

    let err = service.prompt(&config, "hi", None, true).await.unwrap_err();

    assert!(err.contains("lm studio chat failed with status 502"));
}

#[tokio::test]
async fn summarize_disabled_returns_error() {
    // When local_ai is disabled the summarize fn should short-circuit.
    let mut config = Config::default();
    config.local_ai.runtime_enabled = false;
    let service = LocalAiService::new(&config);
    let err = service.summarize(&config, "text", None).await.unwrap_err();
    assert!(err.contains("local ai is disabled"));
}

#[tokio::test]
async fn prompt_disabled_returns_error() {
    let mut config = Config::default();
    config.local_ai.runtime_enabled = false;
    let service = LocalAiService::new(&config);
    let err = service
        .prompt(&config, "text", None, false)
        .await
        .unwrap_err();
    assert!(err.contains("local ai is disabled"));
}

#[tokio::test]
async fn inline_complete_disabled_returns_empty_string() {
    let mut config = Config::default();
    config.local_ai.runtime_enabled = false;
    let service = LocalAiService::new(&config);
    let out = service
        .inline_complete(&config, "ctx", "casual", None, &[], None)
        .await
        .unwrap();
    assert!(out.is_empty());
}

#[tokio::test]
async fn inline_complete_interactive_disabled_returns_empty_string() {
    // Interactive variant must match the gated variant on the
    // disabled short-circuit so the autocomplete UX is identical.
    let mut config = Config::default();
    config.local_ai.runtime_enabled = false;
    let service = LocalAiService::new(&config);
    let out = service
        .inline_complete_interactive(&config, "ctx", "casual", None, &[], None)
        .await
        .unwrap();
    assert!(out.is_empty());
}

/// Interactive autocomplete (`inline_complete_interactive`) MUST NOT
/// block on a held LLM permit. Hold the global slot, race the
/// interactive variant against a tight deadline; if it queued behind
/// the permit it would deadlock or time out.
#[tokio::test]
async fn inline_complete_interactive_does_not_block_on_held_permit() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    // Hold the global LLM permit for the duration of the test.
    let _held = crate::openhuman::scheduler_gate::gate::try_acquire_llm_permit()
        .expect("test must start with a free permit; previous test leaked one");

    let app = Router::new().route(
        "/api/generate",
        post(|Json(_body): Json<serde_json::Value>| async move {
            Json(json!({
                "model": "test",
                "response": "ip",
                "done": true
            }))
        }),
    );
    let base = spawn_mock(app).await;
    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
    }

    let config = enabled_config();
    let service = ready_service(&config);

    // Tight 2s deadline — comfortably above mock RTT, well below any
    // policy-paused-poll backoff. If the interactive call goes through
    // the gate it'll never finish.
    let result = tokio::time::timeout(
        std::time::Duration::from_secs(2),
        service.inline_complete_interactive(&config, "ctx", "casual", None, &[], Some(8)),
    )
    .await;

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }

    let inner = result.expect("interactive variant must NOT block on held permit");
    assert!(
        inner.is_ok(),
        "interactive call should have completed: {inner:?}"
    );
}

#[tokio::test]
async fn prompt_interactive_does_not_block_on_held_permit() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let _held = crate::openhuman::scheduler_gate::gate::try_acquire_llm_permit()
        .expect("test must start with a free permit");

    let app = Router::new().route(
        "/api/generate",
        post(|Json(_body): Json<serde_json::Value>| async move {
            Json(json!({
                "model": "test",
                "response": "hello from mock",
                "done": true
            }))
        }),
    );
    let base = spawn_mock(app).await;
    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
    }

    let config = enabled_config();
    let service = ready_service(&config);

    let result = tokio::time::timeout(
        std::time::Duration::from_secs(2),
        service.prompt_interactive(&config, "hi", Some(16), true),
    )
    .await;

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }

    let reply = result
        .expect("interactive prompt must not block on a held permit")
        .expect("interactive prompt response");
    assert_eq!(reply, "hello from mock");
}

#[tokio::test]
async fn summarize_interactive_does_not_block_on_held_permit() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let _held = crate::openhuman::scheduler_gate::gate::try_acquire_llm_permit()
        .expect("test must start with a free permit");

    let app = Router::new().route(
        "/api/generate",
        post(|Json(body): Json<serde_json::Value>| async move {
            let prompt = body["prompt"].as_str().unwrap_or_default();
            assert!(
                prompt.contains("commitments.\n\ntext to summarize"),
                "summary prompt should use real newlines, got: {prompt:?}"
            );
            assert!(
                !prompt.contains(r"commitments.\n\ntext to summarize"),
                "summary prompt must not contain literal backslash-n separators"
            );
            Json(json!({
                "model": "test",
                "response": "summary from mock",
                "done": true
            }))
        }),
    );
    let base = spawn_mock(app).await;
    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
    }

    let config = enabled_config();
    let service = ready_service(&config);

    let result = tokio::time::timeout(
        std::time::Duration::from_secs(2),
        service.summarize_interactive(&config, "text to summarize", Some(16)),
    )
    .await;

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }

    let reply = result
        .expect("interactive summary must not block on a held permit")
        .expect("interactive summary response");
    assert_eq!(reply, "summary from mock");
}

#[tokio::test]
async fn chat_with_history_interactive_does_not_block_on_held_permit() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let _held = crate::openhuman::scheduler_gate::gate::try_acquire_llm_permit()
        .expect("test must start with a free permit");

    let app = Router::new().route(
        "/api/chat",
        post(|Json(_body): Json<serde_json::Value>| async move {
            Json(json!({
                "model": "test",
                "message": { "role": "assistant", "content": "history reply" },
                "done": true
            }))
        }),
    );
    let base = spawn_mock(app).await;
    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
    }

    let config = enabled_config();
    let service = ready_service(&config);

    let result = tokio::time::timeout(
        std::time::Duration::from_secs(2),
        service.chat_with_history_interactive(
            &config,
            vec![
                crate::openhuman::inference::local::ollama::OllamaChatMessage {
                    role: "user".to_string(),
                    content: "hi".to_string(),
                },
            ],
            Some(16),
        ),
    )
    .await;

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }

    let reply = result
        .expect("interactive chat must not block on a held permit")
        .expect("interactive chat response");
    assert_eq!(reply, "history reply");
}

/// Counterpart: the gated `inline_complete` (and `prompt`/`summarize`)
/// MUST queue behind a held permit. We assert this with a try-style
/// race: spawn the gated call, give it time to enter the wait, then
/// confirm it hasn't completed. We then drop the permit and verify
/// the call resolves.
#[tokio::test]
// Wake-on-permit-drop timing test: under heavy parallel cargo-test load
// the 2s timeout occasionally fires before the spawned waiter resolves.
// Panicking here would poison `LOCAL_AI_TEST_MUTEX` and cascade
// PoisonError into every other local_ai test, so re-ignoring is the
// safer trade-off. See PR #1524.
#[ignore = "flaky timing under full-suite load — see PR #1524"]
async fn gated_inline_complete_blocks_on_held_permit() {
    let _guard = crate::openhuman::inference::inference_test_guard();

    let held = crate::openhuman::scheduler_gate::gate::try_acquire_llm_permit()
        .expect("test must start with a free permit");

    let app = Router::new().route(
        "/api/generate",
        post(|Json(_body): Json<serde_json::Value>| async move {
            Json(json!({
                "model": "test",
                "response": "x",
                "done": true
            }))
        }),
    );
    let base = spawn_mock(app).await;
    unsafe {
        std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base);
    }

    let config = enabled_config();
    let service = std::sync::Arc::new(ready_service(&config));
    let svc = service.clone();
    let cfg = config.clone();

    let join = tokio::spawn(async move {
        svc.inline_complete(&cfg, "ctx", "casual", None, &[], Some(8))
            .await
    });

    // Give the spawned task a chance to enter `wait_for_capacity`.
    tokio::time::sleep(std::time::Duration::from_millis(80)).await;
    assert!(
        !join.is_finished(),
        "gated inline_complete must block while permit is held"
    );

    // Release the permit; the gated call should now resolve.
    drop(held);
    let resolved = tokio::time::timeout(std::time::Duration::from_secs(2), join)
        .await
        .expect("gated call must resolve once permit is released")
        .expect("join")
        .expect("ollama call");
    assert!(!resolved.is_empty() || resolved.is_empty()); // sanity — value depends on sanitiser

    unsafe {
        std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
    }
}
