//! Regression test for the diagnostic surface around an empty-content
//! LLM response.
//!
//! Pre-fix behaviour: when the provider returned `200 OK` with
//! `choices[0].message.content == null` (typical for reasoning models that
//! exhausted `max_completion_tokens` on the chain-of-thought), the worker
//! wrote a failure marker that read `llm: missing choices[0].message.content`
//! — true but useless. Diagnostic fields the provider sent in the same
//! response (`finish_reason`, `usage.completion_tokens`, reasoning_tokens)
//! were dropped on the floor.
//!
//! This test pins the new `LlmError::EmptyContent` variant to the parsed
//! response fields so the failure-marker UI can render structured details.

use doctate_server::analyze::backend::LlmBackend;
use doctate_server::analyze::llm::{LlmError, chat_once};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn test_backend(uri: String) -> LlmBackend {
    LlmBackend {
        id: "test_backend".into(),
        label: "Test".into(),
        url: uri,
        api_key: "test-key".into(),
        requires_api_key: true,
        model_id: "openai/gpt-oss-120b".into(),
        temperature: 0.2,
        top_p: None,
        max_completion_tokens: 16384,
        reasoning_effort: Some("high".into()),
        use_json_schema: false,
        system_prompt: "system".into(),
        format_instruction: None,
        timeout_seconds: 30,
    }
}

#[tokio::test]
async fn empty_content_with_full_usage_surfaces_finish_reason_and_tokens() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "choices": [{
                "message": { "content": null },
                "finish_reason": "length"
            }],
            "usage": {
                "prompt_tokens": 1287,
                "completion_tokens": 16384,
                "completion_tokens_details": {
                    "reasoning_tokens": 14102
                }
            }
        })))
        .mount(&server)
        .await;

    let backend = test_backend(server.uri());
    let client = reqwest::Client::new();
    let err = chat_once(&client, &backend, "user content")
        .await
        .expect_err("must fail when content is null");

    let d = err
        .empty_content_details()
        .expect("must classify as EmptyContent");
    assert_eq!(d.finish_reason, Some("length"));
    assert_eq!(d.prompt_tokens, Some(1287));
    assert_eq!(d.completion_tokens, Some(16384));
    assert_eq!(d.reasoning_tokens, Some(14102));
    assert_eq!(d.max_completion_tokens, 16384);

    // Display string must mention finish_reason and the X/Y token ratio so
    // the failure-marker `reason` field already carries a hint even when
    // structured details are not yet rendered.
    let s = err.to_string();
    assert!(s.contains("finish_reason=length"), "got: {s}");
    assert!(s.contains("16384/16384"), "got: {s}");
    assert!(s.contains("reasoning_tokens=14102"), "got: {s}");
}

#[tokio::test]
async fn empty_content_without_usage_degrades_gracefully() {
    // Some OpenAI-compatible providers omit `usage` entirely on null-content
    // responses. The parser must still classify this as EmptyContent and not
    // crash on the missing field.
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "choices": [{
                "message": { "content": null },
                "finish_reason": "stop"
            }]
        })))
        .mount(&server)
        .await;

    let backend = test_backend(server.uri());
    let client = reqwest::Client::new();
    let err = chat_once(&client, &backend, "user content")
        .await
        .expect_err("must fail when content is null");

    let d = err
        .empty_content_details()
        .expect("must classify as EmptyContent even without usage");
    assert_eq!(d.finish_reason, Some("stop"));
    assert_eq!(d.prompt_tokens, None);
    assert_eq!(d.completion_tokens, None);
    assert_eq!(d.reasoning_tokens, None);
    assert_eq!(d.max_completion_tokens, 16384);
}

#[tokio::test]
async fn populated_content_returns_ok() {
    // Sanity check: the new code path must not regress the happy path.
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "choices": [{
                "message": { "content": "Hallo Welt." },
                "finish_reason": "stop"
            }],
            "usage": {
                "prompt_tokens": 10,
                "completion_tokens": 5
            }
        })))
        .mount(&server)
        .await;

    let backend = test_backend(server.uri());
    let client = reqwest::Client::new();
    let text = chat_once(&client, &backend, "user content")
        .await
        .expect("must succeed when content is populated");
    assert_eq!(text, "Hallo Welt.");
}

#[tokio::test]
async fn http_error_does_not_carry_empty_content_details() {
    // Status errors are a different LlmError variant — `empty_content_details`
    // must not falsely classify them as EmptyContent.
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/chat/completions"))
        .respond_with(ResponseTemplate::new(503).set_body_string("upstream down"))
        .mount(&server)
        .await;

    let backend = test_backend(server.uri());
    let client = reqwest::Client::new();
    let err = chat_once(&client, &backend, "user content")
        .await
        .expect_err("must fail on 503");
    assert!(matches!(err, LlmError::Status { status: 503 }));
    assert!(err.empty_content_details().is_none());
}
