//! Non-streaming Bedrock Converse output → OpenAI **Responses API** mapping.
//!
//! The Responses analogue of [`crate::bedrock::response`] (the chat mapper). It
//! turns a Bedrock Converse output (the full response object as a
//! [`serde_json::Value`]) into a [`ResponsesResponse`]. Pure and synchronous —
//! it never calls Bedrock — so it is fully testable offline with synthetic
//! Converse output JSON.
//!
//! ## Differences from the chat mapper (deliberate)
//!
//! - **Reasoning is a structured output item, NOT `<think>` inline.** When the
//!   Bedrock output carries a `reasoningContent` block, this emits a
//!   [`ResponseOutputItem::Reasoning`] FIRST, carrying the reasoning text in a
//!   `summary` array. The chat path's `<think>...</think>` rendering is
//!   intentionally NOT used here.
//! - **Output items in spec order:** reasoning (if any) → message (text) and/or
//!   function_call items (on `tool_use`).
//! - **Responses usage field names:** `input_tokens` / `output_tokens` /
//!   `total_tokens`, with `input_tokens_details.cached_tokens` and
//!   `output_tokens_details.reasoning_tokens`.
//!
//! ## Shared accounting
//!
//! Token usage is rebuilt via the shared [`compute_token_usage`] helper (the
//! single source of truth, also used by the chat mapper and the streaming usage
//! chunk) — no separate token formula lives here. Reasoning tokens reuse
//! [`estimate_reasoning_tokens`] on the concatenated reasoning text.
//!
//! ## De-hardcoding
//!
//! No model-id literals. The `model` string flows through verbatim from the
//! caller into the response `model` field.

use std::time::{SystemTime, UNIX_EPOCH};

use serde_json::{json, Value};

use crate::bedrock::tokens::{compute_token_usage, estimate_reasoning_tokens};
use crate::error::AppError;
use crate::openai::responses_schema::{
    InputTokensDetails, OutputContentPart, OutputTokensDetails, ResponseOutputItem,
    ResponsesRequest, ResponsesResponse, ResponsesUsage,
};

/// Map a Bedrock Converse output to an OpenAI [`ResponsesResponse`].
///
/// `output` is the full Bedrock Converse response object (carrying
/// `output.message.content`, `usage`, and `stopReason`). `req` is the original
/// Responses request, used to echo back the relevant params (model echoes the
/// resolved `model` argument, not `req.model`). `model` is echoed verbatim into
/// the response; `response_id` becomes the response `id` (a `resp_`-prefixed
/// value generated by the caller).
///
/// Output items are built in spec order:
/// 1. a [`ResponseOutputItem::Reasoning`] FIRST when `reasoningContent` is
///    present (the reasoning text carried in `summary`),
/// 2. then, on a `tool_use` stop reason, one
///    [`ResponseOutputItem::FunctionCall`] per Bedrock `toolUse` block;
///    otherwise a single [`ResponseOutputItem::Message`] coalescing all text
///    blocks into ONE `output_text` content part.
///
/// `status` is `"completed"` unless the stop reason maps to an incomplete state
/// (`max_tokens` → `incomplete_details.reason = "max_output_tokens"`,
/// `content_filtered` → `"content_filter"`).
///
/// # Errors
///
/// Returns [`AppError::Internal`] if the output is missing the
/// `output.message.content` array (a malformed upstream response) or a
/// `toolUse` block's input cannot be serialized.
pub fn from_converse_output_to_responses(
    output: &Value,
    req: &ResponsesRequest,
    model: &str,
    response_id: &str,
) -> Result<ResponsesResponse, AppError> {
    let content = output
        .get("output")
        .and_then(|o| o.get("message"))
        .and_then(|m| m.get("content"))
        .and_then(Value::as_array)
        .ok_or_else(|| {
            AppError::Internal(
                "bedrock converse output missing output.message.content array".to_string(),
            )
        })?;

    let stop_reason = output.get("stopReason").and_then(Value::as_str);

    let usage_value = output.get("usage");
    let counts = compute_token_usage(
        usage_field(usage_value, "inputTokens"),
        usage_field(usage_value, "outputTokens"),
        usage_field(usage_value, "cacheReadInputTokens"),
        usage_field(usage_value, "cacheWriteInputTokens"),
    );

    let reasoning_text = collect_reasoning_text(content);
    let reasoning_tokens = estimate_reasoning_tokens(&reasoning_text);

    let mut items: Vec<ResponseOutputItem> = Vec::new();

    // Spec order: a reasoning item FIRST, carrying the reasoning text in
    // `summary` (a structured item — NOT the chat path's `<think>` inline).
    if !reasoning_text.is_empty() {
        items.push(ResponseOutputItem::Reasoning {
            id: format!("rs_{}", response_id.trim_start_matches("resp_")),
            content: None,
            summary: Some(json!([{ "type": "summary_text", "text": reasoning_text }])),
        });
    }

    if stop_reason == Some("tool_use") {
        // One function_call item per Bedrock toolUse block.
        for part in content.iter().filter_map(|p| p.get("toolUse")) {
            let call_id = part
                .get("toolUseId")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .to_string();
            let name = part
                .get("name")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .to_string();
            let input = part.get("input").cloned().unwrap_or(Value::Null);
            let arguments = serde_json::to_string(&input).map_err(|e| {
                AppError::Internal(format!("failed to serialize toolUse input: {e}"))
            })?;
            items.push(ResponseOutputItem::FunctionCall {
                id: Some(format!("fc_{call_id}")),
                call_id,
                name,
                arguments,
                status: None,
            });
        }
    } else {
        // Coalesce every Bedrock text block into ONE output_text part.
        let mut text = String::new();
        for c in content {
            if let Some(t) = c.get("text").and_then(Value::as_str) {
                text.push_str(t);
            }
        }
        items.push(ResponseOutputItem::Message {
            id: format!("msg_{}", response_id.trim_start_matches("resp_")),
            status: "completed".to_string(),
            role: "assistant".to_string(),
            content: vec![OutputContentPart::OutputText {
                text,
                annotations: Vec::new(),
                logprobs: None,
            }],
        });
    }

    let (status, incomplete_details) = map_status(stop_reason);

    let input_tokens_details = Some(InputTokensDetails {
        cached_tokens: counts.cached_tokens,
    });
    let output_tokens_details = Some(OutputTokensDetails {
        reasoning_tokens: reasoning_tokens as i32,
    });

    Ok(ResponsesResponse {
        id: response_id.to_string(),
        object: "response".to_string(),
        created_at: now_unix_secs(),
        status,
        output: items,
        usage: ResponsesUsage {
            input_tokens: counts.prompt_tokens,
            input_tokens_details,
            output_tokens: counts.completion_tokens,
            output_tokens_details,
            total_tokens: counts.total_tokens,
        },
        model: model.to_string(),
        instructions: req.instructions.clone(),
        temperature: req.temperature,
        top_p: req.top_p,
        tool_choice: req.tool_choice.clone(),
        tools: req.tools.clone(),
        max_output_tokens: req.max_output_tokens,
        parallel_tool_calls: req.parallel_tool_calls,
        error: None,
        incomplete_details,
    })
}

/// Concatenate every `reasoningContent.reasoningText.text` across blocks.
fn collect_reasoning_text(content: &[Value]) -> String {
    let mut reasoning = String::new();
    for block in content {
        if let Some(t) = block
            .get("reasoningContent")
            .and_then(|rc| rc.get("reasoningText"))
            .and_then(|rt| rt.get("text"))
            .and_then(Value::as_str)
        {
            reasoning.push_str(t);
        }
    }
    reasoning
}

/// Map a Bedrock `stopReason` to a Responses `status` plus optional
/// `incomplete_details`.
///
/// `max_tokens` → `("incomplete", { "reason": "max_output_tokens" })`;
/// `content_filtered` → `("incomplete", { "reason": "content_filter" })`;
/// everything else → `("completed", None)`.
fn map_status(stop_reason: Option<&str>) -> (String, Option<Value>) {
    match stop_reason.map(str::to_lowercase).as_deref() {
        Some("max_tokens") => (
            "incomplete".to_string(),
            Some(json!({ "reason": "max_output_tokens" })),
        ),
        Some("content_filtered") => (
            "incomplete".to_string(),
            Some(json!({ "reason": "content_filter" })),
        ),
        _ => ("completed".to_string(), None),
    }
}

/// Read an integer usage field, defaulting to 0 (matches the chat mapper).
fn usage_field(usage: Option<&Value>, key: &str) -> i32 {
    usage
        .and_then(|u| u.get(key))
        .and_then(Value::as_i64)
        .unwrap_or(0) as i32
}

/// Current Unix time in whole seconds.
fn now_unix_secs() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::openai::responses_schema::ResponsesInput;
    use std::collections::HashMap;

    fn req() -> ResponsesRequest {
        ResponsesRequest {
            model: "incoming".to_string(),
            input: ResponsesInput::Text("hi".to_string()),
            instructions: Some("be terse".to_string()),
            tools: None,
            tool_choice: None,
            temperature: Some(0.5),
            top_p: Some(0.9),
            max_output_tokens: Some(256),
            stream: None,
            reasoning: None,
            text: None,
            include: None,
            metadata: None,
            parallel_tool_calls: Some(true),
            store: None,
            previous_response_id: None,
            extra: HashMap::new(),
        }
    }

    // -- Test 1: text output → message item with output_text; no <think> ------

    #[test]
    fn text_output_becomes_message_with_output_text() {
        let output = json!({
            "output": { "message": { "role": "assistant", "content": [{ "text": "Hello" }] } },
            "stopReason": "end_turn",
            "usage": { "inputTokens": 8, "outputTokens": 2, "totalTokens": 10 }
        });
        let resp =
            from_converse_output_to_responses(&output, &req(), "m", "resp_abc").expect("map text");

        assert_eq!(resp.id, "resp_abc");
        assert_eq!(resp.object, "response");
        assert_eq!(resp.status, "completed");
        assert_eq!(resp.model, "m");
        // Exactly one message item with one output_text part.
        assert_eq!(resp.output.len(), 1);
        match &resp.output[0] {
            ResponseOutputItem::Message { role, content, .. } => {
                assert_eq!(role, "assistant");
                assert_eq!(content.len(), 1);
                match &content[0] {
                    OutputContentPart::OutputText { text, .. } => assert_eq!(text, "Hello"),
                    other => panic!("expected output_text part, got {other:?}"),
                }
            }
            other => panic!("expected message item, got {other:?}"),
        }

        // Echoed request params.
        assert_eq!(resp.instructions.as_deref(), Some("be terse"));
        assert_eq!(resp.temperature, Some(0.5));
        assert_eq!(resp.top_p, Some(0.9));
        assert_eq!(resp.max_output_tokens, Some(256));
        assert_eq!(resp.parallel_tool_calls, Some(true));

        // No <think>, no top-level output_text key, id matches ^resp_.
        let v = serde_json::to_value(&resp).expect("serialize");
        let s = serde_json::to_string(&resp).expect("string");
        assert!(!s.contains("<think>"), "<think> leaked: {s}");
        assert!(
            v.get("output_text").is_none(),
            "output_text wire key present"
        );
        assert!(resp.id.starts_with("resp_"));
    }

    /// REGRESSION (@ai-sdk/openai non-stream `generateText`): `output_text`
    /// MUST serialize `annotations` as a present array (`[]` when empty).
    /// ai-sdk declares `annotations: z.array(...)` without `.nullish()`, so an
    /// absent field fails validation ("Invalid JSON response").
    #[test]
    fn output_text_serializes_annotations_as_empty_array() {
        let output = json!({
            "output": { "message": { "role": "assistant", "content": [{ "text": "pong" }] } },
            "stopReason": "end_turn",
            "usage": { "inputTokens": 1, "outputTokens": 1, "totalTokens": 2 }
        });
        let resp =
            from_converse_output_to_responses(&output, &req(), "m", "resp_ann").expect("map text");
        let v = serde_json::to_value(&resp).expect("serialize");

        let part = &v["output"][0]["content"][0];
        assert_eq!(part["type"], "output_text");
        assert_eq!(part["text"], "pong");
        assert!(
            part.get("annotations").is_some(),
            "annotations key absent — ai-sdk would reject: {part}"
        );
        assert_eq!(
            part["annotations"],
            json!([]),
            "annotations must serialize as an empty array: {part}"
        );
        assert!(
            part["annotations"].is_array(),
            "annotations must be an array"
        );
    }

    // -- Test 2: tool_use → function_call item with call_id/name/arguments ----

    #[test]
    fn tool_use_output_becomes_function_call_items() {
        let output = json!({
            "output": { "message": { "role": "assistant", "content": [
                { "toolUse": {
                    "toolUseId": "call-1",
                    "name": "get_weather",
                    "input": { "city": "Paris", "unit": "c" }
                }}
            ] } },
            "stopReason": "tool_use",
            "usage": { "inputTokens": 12, "outputTokens": 6, "totalTokens": 18 }
        });
        let resp = from_converse_output_to_responses(&output, &req(), "m", "resp_x")
            .expect("map tool_use");

        assert_eq!(resp.output.len(), 1);
        match &resp.output[0] {
            ResponseOutputItem::FunctionCall {
                call_id,
                name,
                arguments,
                ..
            } => {
                assert_eq!(call_id, "call-1");
                assert_eq!(name, "get_weather");
                let args: Value = serde_json::from_str(arguments).expect("args json");
                assert_eq!(args, json!({ "city": "Paris", "unit": "c" }));
            }
            other => panic!("expected function_call item, got {other:?}"),
        }
    }

    // -- Test 3: reasoning present → reasoning item FIRST, then message --------

    #[test]
    fn reasoning_emits_reasoning_item_first_then_message() {
        let output = json!({
            "output": { "message": { "role": "assistant", "content": [
                { "reasoningContent": { "reasoningText": { "text": "think step by step" } } },
                { "text": "The answer is 4." }
            ] } },
            "stopReason": "end_turn",
            "usage": { "inputTokens": 10, "outputTokens": 5, "totalTokens": 15 }
        });
        let resp = from_converse_output_to_responses(&output, &req(), "m", "resp_r")
            .expect("map reasoning");

        // reasoning FIRST, then the message.
        assert_eq!(resp.output.len(), 2);
        match &resp.output[0] {
            ResponseOutputItem::Reasoning { summary, .. } => {
                let summary = summary.as_ref().expect("summary present");
                assert_eq!(summary[0]["text"], "think step by step");
            }
            other => panic!("expected reasoning item first, got {other:?}"),
        }
        match &resp.output[1] {
            ResponseOutputItem::Message { content, .. } => match &content[0] {
                OutputContentPart::OutputText { text, .. } => {
                    assert_eq!(text, "The answer is 4.");
                    // Reasoning is NOT rendered inline as <think>.
                    assert!(!text.contains("<think>"));
                }
                other => panic!("expected output_text part, got {other:?}"),
            },
            other => panic!("expected message item second, got {other:?}"),
        }

        // No <think> anywhere on the wire.
        let s = serde_json::to_string(&resp).expect("serialize");
        assert!(!s.contains("<think>"), "<think> leaked: {s}");
    }

    // -- Test 4: usage via shared helper → Responses field names --------------

    #[test]
    fn usage_uses_shared_helper_and_responses_field_names() {
        // input=9, cacheRead=4, cacheWrite=7, output=5.
        // Shared helper: prompt = 9+4+7 = 20; total = 20+5 = 25; cached = 4.
        let output = json!({
            "output": { "message": { "content": [
                { "reasoningContent": { "reasoningText": { "text": "deliberation" } } },
                { "text": "ok" }
            ] } },
            "stopReason": "end_turn",
            "usage": {
                "inputTokens": 9,
                "outputTokens": 5,
                "totalTokens": 20,
                "cacheReadInputTokens": 4,
                "cacheWriteInputTokens": 7
            }
        });
        let resp =
            from_converse_output_to_responses(&output, &req(), "m", "resp_u").expect("map usage");

        // Matches compute_token_usage exactly.
        let expected = compute_token_usage(9, 5, 4, 7);
        assert_eq!(resp.usage.input_tokens, expected.prompt_tokens);
        assert_eq!(resp.usage.output_tokens, expected.completion_tokens);
        assert_eq!(resp.usage.total_tokens, expected.total_tokens);
        assert_eq!(
            resp.usage
                .input_tokens_details
                .as_ref()
                .expect("input details")
                .cached_tokens,
            expected.cached_tokens
        );
        // reasoning_tokens = estimate of the reasoning text.
        let rt = resp
            .usage
            .output_tokens_details
            .as_ref()
            .expect("output details")
            .reasoning_tokens;
        assert_eq!(rt, estimate_reasoning_tokens("deliberation") as i32);
        assert!(rt > 0);

        // Responses field names on the wire — NOT chat names.
        let v = serde_json::to_value(&resp).expect("serialize");
        assert_eq!(v["usage"]["input_tokens"], expected.prompt_tokens);
        assert_eq!(v["usage"]["output_tokens"], expected.completion_tokens);
        assert_eq!(v["usage"]["total_tokens"], expected.total_tokens);
        assert!(v["usage"].get("prompt_tokens").is_none());
        assert!(v["usage"].get("completion_tokens").is_none());
        assert_eq!(v["usage"]["input_tokens_details"]["cached_tokens"], 4);
        assert!(v["usage"]["output_tokens_details"]["reasoning_tokens"]
            .as_i64()
            .is_some());
    }

    // -- status mapping for incomplete stop reasons ---------------------------

    #[test]
    fn max_tokens_maps_to_incomplete_max_output_tokens() {
        let output = json!({
            "output": { "message": { "content": [{ "text": "truncated" }] } },
            "stopReason": "max_tokens",
            "usage": { "inputTokens": 1, "outputTokens": 1, "totalTokens": 2 }
        });
        let resp = from_converse_output_to_responses(&output, &req(), "m", "resp_t").expect("map");
        assert_eq!(resp.status, "incomplete");
        assert_eq!(
            resp.incomplete_details.expect("details")["reason"],
            "max_output_tokens"
        );
    }

    #[test]
    fn content_filtered_maps_to_incomplete_content_filter() {
        let output = json!({
            "output": { "message": { "content": [{ "text": "" }] } },
            "stopReason": "content_filtered",
            "usage": { "inputTokens": 1, "outputTokens": 0, "totalTokens": 1 }
        });
        let resp = from_converse_output_to_responses(&output, &req(), "m", "resp_c").expect("map");
        assert_eq!(resp.status, "incomplete");
        assert_eq!(
            resp.incomplete_details.expect("details")["reason"],
            "content_filter"
        );
    }

    #[test]
    fn missing_content_array_errors() {
        let output = json!({ "output": { "message": {} }, "stopReason": "end_turn" });
        let err = from_converse_output_to_responses(&output, &req(), "m", "resp_e")
            .expect_err("should error");
        assert!(matches!(err, AppError::Internal(_)));
    }

    #[test]
    fn output_text_part_always_serializes_annotations_as_empty_array() {
        let output = json!({
            "output": { "message": { "role": "assistant", "content": [{ "text": "pong" }] } },
            "stopReason": "end_turn",
            "usage": { "inputTokens": 1, "outputTokens": 1, "totalTokens": 2 }
        });
        let resp =
            from_converse_output_to_responses(&output, &req(), "m", "resp_ann").expect("map text");
        let v = serde_json::to_value(&resp).expect("serialize");
        let part = &v["output"][0]["content"][0];
        assert_eq!(part["type"], "output_text");
        assert_eq!(part["text"], "pong");
        assert!(
            part.get("annotations").is_some(),
            "annotations key must always be present: {part}"
        );
        assert_eq!(
            part["annotations"],
            json!([]),
            "empty annotations must serialize as []: {part}"
        );
        let s = serde_json::to_string(&resp).expect("string");
        assert!(
            s.contains("\"annotations\":[]"),
            "serialized JSON must contain annotations:[] : {s}"
        );
    }
}
