//! Happy-path integration tests using MockServer's Reflective Script.
//!
//! These tests exercise the full stack:
//! - OpenAiChannel sends a real HTTP request to MockServer
//! - MockServer inspects the request body, extracts the token that infer()
//!   embedded in the schema, and responds with a token-matched valid LLM output
//! - infer() consumes the SSE stream and parses into Parseable Response

mod common;

use common::mock_llm_server::{CapturedRequest, MockLlmServer, Script};
use mad_hatter::{infer, OpenAiChannel, Parseable, Promptable};
use std::sync::Arc;

// ============================================================
// Test types
// ============================================================

#[derive(Promptable)]
#[prompt("Test request")]
struct TestRequest {
    #[prompt("Message")]
    message: String,
}

#[derive(Parseable, PartialEq, Debug)]
enum TestAction {
    #[prompt("Reply to user")]
    Reply { content: String },
    #[prompt("Do nothing")]
    Idle,
}

// ============================================================
// Helpers: extract token from prompt body, build matched output
// ============================================================

/// Extract the action token from a captured request body.
///
/// The body is JSON of the OpenAI chat completion request. The schema for
/// TestAction is embedded in one of the `messages` contents in the form
/// `TestAction-<token>` (variant discriminator) and `end-marker-<token>`
/// (end marker).
///
/// Pick the first `TestAction-<xxx>` occurrence that is NOT `end-marker-`.
fn extract_action_token(body: &str) -> String {
    let prefix = "TestAction-";
    let mut search_pos = 0;
    while let Some(pos) = body[search_pos..].find(prefix) {
        let abs = search_pos + pos;
        let after = &body[abs + prefix.len()..];
        // Skip "end-" variant
        if after.starts_with("end-") {
            search_pos = abs + prefix.len() + "end-".len();
            continue;
        }
        // Take alphanumeric token
        let tok_end = after
            .find(|c: char| !c.is_ascii_alphanumeric())
            .unwrap_or(after.len());
        if tok_end > 0 {
            return after[..tok_end].to_string();
        }
        search_pos = abs + prefix.len();
    }
    panic!(
        "no TestAction-<token> found in body (len={}): {}...",
        body.len(),
        &body[..body.len().min(300)]
    );
}

fn build_idle_output(token: &str) -> String {
    // NOTE: Parseable schema emits variant names in snake_case (see llm.rs to_snake_case).
    // LLM output (and thus mock response) must match—`Idle` → `idle`, `SendMsg` → `send_msg`, etc.
    format!(
        "TestAction-{tok}\nidle\nend-marker-{tok}\n",
        tok = token
    )
}

fn build_reply_output(token: &str, content: &str) -> String {
    format!(
        "TestAction-{tok}\nreply\ncontent-{tok}\n{c}\nend-marker-{tok}\n",
        tok = token,
        c = content
    )
}

// ============================================================
// Tests
// ============================================================

#[tokio::test]
async fn test_infer_happy_path_idle() {
    let mock = MockLlmServer::start().await;
    mock.push_script(Script::Reflective(Arc::new(|req: &CapturedRequest| {
        let token = extract_action_token(&req.body);
        Script::SseContent {
            chunks: vec![build_idle_output(&token)],
            finish_reason: "stop".to_string(),
        }
    })))
    .await;

    let channel = OpenAiChannel::new(mock.chat_url(), "test-model", "test-key");
    let req = TestRequest {
        message: "hello".to_string(),
    };

    let result = infer::<_, TestAction>(&channel, &req, false).await;
    let (action, _usage) = result.expect("infer should succeed");
    assert_eq!(action, TestAction::Idle);
}

#[tokio::test]
async fn test_infer_happy_path_reply_with_content() {
    let mock = MockLlmServer::start().await;
    mock.push_script(Script::Reflective(Arc::new(|req: &CapturedRequest| {
        let token = extract_action_token(&req.body);
        Script::SseContent {
            chunks: vec![build_reply_output(&token, "hello world")],
            finish_reason: "stop".to_string(),
        }
    })))
    .await;

    let channel = OpenAiChannel::new(mock.chat_url(), "test-model", "test-key");
    let req = TestRequest {
        message: "hi".to_string(),
    };

    let result = infer::<_, TestAction>(&channel, &req, false).await;
    let (action, _usage) = result.expect("infer should succeed");
    assert_eq!(
        action,
        TestAction::Reply {
            content: "hello world".to_string()
        }
    );
}

#[tokio::test]
async fn test_infer_happy_path_multi_chunk_reply() {
    let mock = MockLlmServer::start().await;
    mock.push_script(Script::Reflective(Arc::new(|req: &CapturedRequest| {
        let token = extract_action_token(&req.body);
        let full = build_reply_output(&token, "streamed in pieces");
        // Split into 4 chunks to exercise SSE chunk concatenation path
        let chunk_size = full.len().div_ceil(4);
        let mut chunks = Vec::new();
        let mut cursor = 0;
        while cursor < full.len() {
            let end = (cursor + chunk_size).min(full.len());
            // Find a safe UTF-8 boundary
            let mut safe_end = end;
            while safe_end > cursor && !full.is_char_boundary(safe_end) {
                safe_end -= 1;
            }
            if safe_end == cursor {
                safe_end = full.len();
            }
            chunks.push(full[cursor..safe_end].to_string());
            cursor = safe_end;
        }
        Script::SseContent {
            chunks,
            finish_reason: "stop".to_string(),
        }
    })))
    .await;

    let channel = OpenAiChannel::new(mock.chat_url(), "test-model", "test-key");
    let req = TestRequest {
        message: "go".to_string(),
    };

    let result = infer::<_, TestAction>(&channel, &req, false).await;
    let (action, _usage) = result.expect("infer should succeed");
    assert_eq!(
        action,
        TestAction::Reply {
            content: "streamed in pieces".to_string()
        }
    );
}

/// Scenario: end-marker 后有废话 → 废话被丢弃，最后 item 正常 yield
///
/// Contract: end-marker-{token} 是全局结束信号，parse 时作为 strip_suffix。
/// 即使模型在 end-marker 之后又多输出了一些 token（幻觉尾巴），
/// 只要 finish_reason=stop（说明是 OpenAI 正常停止，非中途断开），
/// 最后 item 应当正常 yield。
#[tokio::test]
async fn test_trailing_garbage_after_end_marker_is_discarded() {
    let mock = MockLlmServer::start().await;
    mock.push_script(Script::Reflective(Arc::new(|req: &CapturedRequest| {
        let token = extract_action_token(&req.body);
        // end-marker 之后追加废话（模拟模型跑偏但被 stop 参数硬停前已吐出的尾巴）
        let output = format!(
            "TestAction-{tok}\nreply\ncontent-{tok}\nhello world\nend-marker-{tok}\n      Խ\n      f\n      EOF\n",
            tok = token
        );
        Script::SseContent {
            chunks: vec![output],
            finish_reason: "stop".to_string(),
        }
    })))
    .await;

    let channel = OpenAiChannel::new(mock.chat_url(), "test-model", "test-key");
    let req = TestRequest {
        message: "hi".to_string(),
    };

    let result = infer::<_, TestAction>(&channel, &req, false).await;
    let (action, _usage) = result.expect("infer should succeed despite trailing garbage");
    assert_eq!(
        action,
        TestAction::Reply {
            content: "hello world".to_string()
        },
        "trailing garbage after end-marker must be discarded, content should be clean"
    );
}

/// Scenario: 模型忘记输出 end-marker，但 finish_reason=stop（[stop] 优雅结束）
///
/// Contract: end-marker 是 optional 的（大模型有时会忘）。
/// 只要 finish_reason=stop（OpenAI 正常停止信号），就是优雅结束，
/// 最后 item 应当正常 yield，不应当因缺 end-marker 而丢弃。
#[tokio::test]
async fn test_stop_without_end_marker_still_yields() {
    let mock = MockLlmServer::start().await;
    mock.push_script(Script::Reflective(Arc::new(|req: &CapturedRequest| {
        let token = extract_action_token(&req.body);
        // 注意：输出中故意不带 end-marker（模型忘了）
        let output = format!(
            "TestAction-{tok}\nreply\ncontent-{tok}\nhello world\n",
            tok = token
        );
        Script::SseContent {
            chunks: vec![output],
            finish_reason: "stop".to_string(),
        }
    })))
    .await;

    let channel = OpenAiChannel::new(mock.chat_url(), "test-model", "test-key");
    let req = TestRequest {
        message: "hi".to_string(),
    };

    let result = infer::<_, TestAction>(&channel, &req, false).await;
    let (action, _usage) = result.expect("infer should succeed when finish_reason=stop even without end-marker");
    assert_eq!(
        action,
        TestAction::Reply {
            content: "hello world".to_string()
        },
        "[stop] alone is a valid graceful termination signal"
    );
}

/// Protocol self-check: infer() must put global end-marker into OpenAI `stop`.
///
/// This proves the HTTP request body sent to an OpenAI-compatible endpoint contains:
/// `"stop": ["end-marker-{token}"]`
#[tokio::test]
async fn test_openai_request_contains_end_marker_stop_sequence() {
    let mock = MockLlmServer::start().await;
    mock.push_script(Script::Reflective(Arc::new(|req: &CapturedRequest| {
        let token = extract_action_token(&req.body);
        Script::SseContent {
            chunks: vec![build_idle_output(&token)],
            finish_reason: "stop".to_string(),
        }
    })))
    .await;

    let channel = OpenAiChannel::new(mock.chat_url(), "test-model", "test-key");
    let req = TestRequest {
        message: "check stop".to_string(),
    };

    let (action, _usage) = infer::<_, TestAction>(&channel, &req, false)
        .await
        .expect("infer should succeed");
    assert_eq!(action, TestAction::Idle);

    let requests = mock.requests().await;
    assert_eq!(requests.len(), 1, "expected exactly one HTTP request");

    let body_json: serde_json::Value =
        serde_json::from_str(&requests[0].body).expect("request body should be JSON");
    let token = extract_action_token(&requests[0].body);
    let expected_stop = format!("end-marker-{}", token);

    assert_eq!(
        body_json
            .get("stop")
            .and_then(|v| v.as_array())
            .and_then(|arr| arr.first())
            .and_then(|v| v.as_str()),
        Some(expected_stop.as_str()),
        "OpenAI request body must contain stop=[{}], body={}",
        expected_stop,
        requests[0].body
    );
}