"""OpenAI Responses provider — message/tool conversion, complete(), stream(), sidecar
replay, param-fix retries. SDK-free: the fake client mimics the OpenAI SDK's
`responses.create` surface with dicts/SimpleNamespace objects, the same pattern the
Gemini/Anthropic provider tests use."""

from __future__ import annotations

import json
from types import SimpleNamespace

import pytest

from coworker.providers.openai_responses import (
    OpenAIResponsesProvider,
    _param_fix_retry,
    convert_messages,
    convert_tools,
)


def test_responses_custom_base_url_reaches_sdk(monkeypatch):
    captured: dict = {}

    def fake_openai(**kwargs):
        captured.update(kwargs)
        return SimpleNamespace()

    monkeypatch.setattr("openai.OpenAI", fake_openai)
    provider = OpenAIResponsesProvider(
        api_key="ark-key",
        base_url="https://ark.example/api/v3/",
    )

    provider._ensure_client()

    assert captured == {
        "api_key": "ark-key",
        "base_url": "https://ark.example/api/v3",
    }


def test_stock_openai_responses_path_unchanged(monkeypatch):
    """Lockdown: stock OpenAI must not receive a vendor base URL."""
    captured: dict = {}

    def fake_openai(**kwargs):
        captured.update(kwargs)
        return SimpleNamespace()

    monkeypatch.setattr("openai.OpenAI", fake_openai)
    provider = OpenAIResponsesProvider(api_key="openai-key")

    provider._ensure_client()

    assert captured == {"api_key": "openai-key"}

# -- fakes ------------------------------------------------------------------------


class _FakeClient:
    """Records the kwargs passed to responses.create; raises queued errors first (to
    exercise the param-fix retries), then returns the canned response — or, when the
    request asked for stream=True, an iterator of canned events."""

    def __init__(self, response=None, events=None, errors=None):
        self.kwargs: dict = {}
        self.calls: list[dict] = []
        errors = list(errors or [])

        def create(**kwargs):
            self.kwargs = kwargs
            self.calls.append(kwargs)
            if errors:
                raise errors.pop(0)
            if kwargs.get("stream"):
                return iter(events or [])
            return response

        self.responses = SimpleNamespace(create=create)


def _response(output, status="completed", incomplete_details=None):
    return SimpleNamespace(
        output=output, status=status, incomplete_details=incomplete_details
    )


def _message_item(text):
    return {
        "type": "message",
        "id": "msg_1",
        "role": "assistant",
        "content": [{"type": "output_text", "text": text}],
    }


def _reasoning_item(summaries, encrypted="enc-blob"):
    item = {
        "type": "reasoning",
        "id": "rs_1",
        "summary": [{"type": "summary_text", "text": s} for s in summaries],
    }
    if encrypted:
        item["encrypted_content"] = encrypted
    return item


def _call_item(call_id, name, arguments):
    return {
        "type": "function_call",
        "id": f"fc_{call_id}",
        "call_id": call_id,
        "name": name,
        "arguments": arguments,
    }


# -- message conversion -------------------------------------------------------------


def test_convert_extracts_leading_system_as_instructions():
    instructions, items = convert_messages(
        [
            {"role": "system", "content": "be helpful"},
            {"role": "system", "content": "be brief"},
            {"role": "user", "content": "hi"},
        ]
    )
    assert instructions == "be helpful\n\nbe brief"
    assert items == [{"role": "user", "content": "hi"}]


def test_convert_mid_thread_system_stays_a_message():
    _, items = convert_messages(
        [
            {"role": "user", "content": "hi"},
            {"role": "system", "content": "steering"},
        ]
    )
    assert items[1] == {"role": "system", "content": "steering"}


def test_convert_user_parts_to_input_parts():
    _, items = convert_messages(
        [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "what is this"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="},
                    },
                    {
                        "type": "file",
                        "file": {
                            "filename": "report.pdf",
                            "file_data": "data:application/pdf;base64,JVBERi0=",
                        },
                    },
                ],
            }
        ]
    )
    assert items[0]["content"] == [
        {"type": "input_text", "text": "what is this"},
        {"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="},
        {
            "type": "input_file",
            "filename": "report.pdf",
            "file_data": "data:application/pdf;base64,JVBERi0=",
        },
    ]


def test_convert_synthesizes_assistant_and_tool_items():
    # No `_openai` sidecar (history from another provider): items are rebuilt from the
    # canonical fields, and foreign toolu_ ids still pair call → output.
    _, items = convert_messages(
        [
            {"role": "user", "content": "go"},
            {
                "role": "assistant",
                "content": "on it",
                "tool_calls": [
                    {
                        "id": "toolu_abc",
                        "type": "function",
                        "function": {"name": "f", "arguments": '{"x": 1}'},
                    }
                ],
            },
            {"role": "tool", "tool_call_id": "toolu_abc", "content": '{"ok": true}'},
        ]
    )
    assert items[1] == {"role": "assistant", "content": "on it"}
    assert items[2] == {
        "type": "function_call",
        "call_id": "toolu_abc",
        "name": "f",
        "arguments": '{"x": 1}',
    }
    assert items[3] == {
        "type": "function_call_output",
        "call_id": "toolu_abc",
        "output": '{"ok": true}',
    }


def test_convert_replays_openai_sidecar_verbatim():
    sidecar_items = [
        _reasoning_item(["thinking"], encrypted="blob"),
        _message_item("on it"),
        _call_item("call_1", "f", "{}"),
    ]
    _, items = convert_messages(
        [
            {"role": "user", "content": "go"},
            {
                "role": "assistant",
                "content": "on it",
                "tool_calls": [
                    {"id": "call_1", "function": {"name": "f", "arguments": "{}"}}
                ],
                "_openai": {"items": sidecar_items},
            },
            {"role": "tool", "tool_call_id": "call_1", "content": "done"},
        ]
    )
    # The sidecar items go in verbatim — no synthesized duplicates alongside.
    assert items[1:4] == sidecar_items
    assert items[4]["type"] == "function_call_output"


def test_convert_ignores_foreign_sidecars():
    _, items = convert_messages(
        [
            {"role": "user", "content": "go"},
            {
                "role": "assistant",
                "content": "hi",
                "_gemini": {"text_sig": "abc"},
            },
        ]
    )
    assert items[1] == {"role": "assistant", "content": "hi"}


def test_convert_empty_assistant_tool_turn_emits_no_message_item():
    _, items = convert_messages(
        [
            {"role": "user", "content": "go"},
            {
                "role": "assistant",
                "content": "",
                "tool_calls": [
                    {"id": "c1", "function": {"name": "f", "arguments": "{}"}}
                ],
            },
        ]
    )
    assert [i.get("type") for i in items[1:]] == ["function_call"]


# -- tool schema conversion ----------------------------------------------------------


def test_convert_tools_flattens_function_schemas():
    tools = convert_tools(
        [
            {"type": "function", "function": {"name": "bare"}},
            {
                "type": "function",
                "function": {
                    "name": "full",
                    "description": "does things",
                    "parameters": {
                        "type": "object",
                        "properties": {"x": {"type": "integer"}},
                    },
                },
            },
        ]
    )
    assert tools[0] == {"type": "function", "name": "bare"}
    assert tools[1]["name"] == "full" and "function" not in tools[1]
    assert tools[1]["parameters"]["properties"] == {"x": {"type": "integer"}}
    assert convert_tools(None) == []


# -- complete() ----------------------------------------------------------------------


def test_complete_default_request_shape_PathsUnchanged():
    fake = _FakeClient(response=_response([_message_item("hello")]))
    provider = OpenAIResponsesProvider(client=fake)
    turn = provider.complete(
        model="gpt-5.6-sol",
        messages=[
            {"role": "system", "content": "sys"},
            {"role": "user", "content": "hi"},
        ],
    )
    assert turn.text == "hello" and turn.finish_reason == "stop"
    assert not turn.has_tool_calls and turn.extras == {}
    assert fake.kwargs["model"] == "gpt-5.6-sol"
    assert fake.kwargs["instructions"] == "sys"
    assert fake.kwargs["store"] is False
    assert fake.kwargs["include"] == ["reasoning.encrypted_content"]
    assert fake.kwargs["reasoning"] == {"summary": "auto"}


def test_complete_extracts_usage_with_cache_split():
    # OPE-101: the Responses API reports `input_tokens` INCLUSIVE of the cached share;
    # normalized like every other adapter — fresh input = input − cached, cache_read
    # carries the cached share. Before this, the field was dropped entirely and every
    # Responses-routed model metered as 0 tokens.
    resp = _response([_message_item("hello")])
    resp.usage = SimpleNamespace(
        input_tokens=1500,
        output_tokens=80,
        input_tokens_details=SimpleNamespace(cached_tokens=1400),
    )
    provider = OpenAIResponsesProvider(client=_FakeClient(response=resp))
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "hi"}])
    assert turn.usage is not None
    assert (turn.usage.input, turn.usage.output, turn.usage.cache_read) == (100, 80, 1400)


def test_complete_usage_degrades_on_partial_or_missing_fields():
    # Compat/older servers may omit `input_tokens_details` or the whole usage object —
    # never a crash, and absence stays None (not a fake zero-usage).
    resp = _response([_message_item("x")])
    resp.usage = SimpleNamespace(input_tokens=500, output_tokens=20)  # no details
    provider = OpenAIResponsesProvider(client=_FakeClient(response=resp))
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "hi"}])
    assert (turn.usage.input, turn.usage.output, turn.usage.cache_read) == (500, 20, 0)

    bare = _response([_message_item("y")])  # SimpleNamespace without a usage attr at all
    turn2 = OpenAIResponsesProvider(client=_FakeClient(response=bare)).complete(
        model="m", messages=[{"role": "user", "content": "hi"}]
    )
    assert turn2.usage is None
def test_complete_can_omit_reasoning_summary_but_keep_encrypted_content():
    """BytePlus accepts encrypted reasoning output but rejects reasoning.summary."""
    fake = _FakeClient(response=_response([_message_item("hello")]))
    provider = OpenAIResponsesProvider(client=fake, reasoning_summary=False)

    provider.complete(model="m", messages=[{"role": "user", "content": "hi"}])

    assert "reasoning" not in fake.kwargs
    assert fake.kwargs["include"] == ["reasoning.encrypted_content"]


def test_reasoning_summary_capability_rejects_unknown_mode():
    with pytest.raises(TypeError, match="reasoning_summary must be a bool"):
        OpenAIResponsesProvider(client=SimpleNamespace(), reasoning_summary="auto")


def test_complete_parses_function_calls_with_call_ids():
    fake = _FakeClient(
        response=_response(
            [
                _message_item("on it"),
                _call_item("call_a", "write_file", '{"path": "a.txt"}'),
                _call_item("call_b", "read_file", "not json"),
            ]
        )
    )
    provider = OpenAIResponsesProvider(client=fake)
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
    assert turn.text == "on it" and turn.finish_reason == "tool_calls"
    assert [(c.id, c.name) for c in turn.tool_calls] == [
        ("call_a", "write_file"),
        ("call_b", "read_file"),
    ]
    assert turn.tool_calls[0].arguments == {"path": "a.txt"}
    assert turn.tool_calls[1].arguments == {"_raw": "not json"}


def test_complete_surfaces_reasoning_summary_and_sidecar():
    items = [
        _reasoning_item(["plan a", " then b"], encrypted="blob"),
        _message_item("answer"),
        _call_item("call_1", "f", "{}"),
    ]
    provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
    assert turn.reasoning == "plan a then b"
    assert turn.extras["_openai"]["items"] == items


def test_complete_drops_unresolvable_reasoning_from_sidecar():
    # No encrypted_content (e.g. `include` got param-fix-dropped): replaying the item
    # under store:false would 400, so it must not enter the sidecar.
    items = [
        _reasoning_item(["hmm"], encrypted=None),
        _call_item("call_1", "f", "{}"),
    ]
    provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
    assert turn.reasoning == "hmm"  # still displayed…
    kinds = [i["type"] for i in turn.extras["_openai"]["items"]]
    assert kinds == ["function_call"]  # …but never replayed


def test_complete_plain_text_has_no_sidecar():
    provider = OpenAIResponsesProvider(
        client=_FakeClient(response=_response([_message_item("plain")]))
    )
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
    assert turn.extras == {}


def test_complete_maps_incomplete_max_tokens_to_length():
    provider = OpenAIResponsesProvider(
        client=_FakeClient(
            response=_response(
                [_message_item("truncat")],
                status="incomplete",
                incomplete_details=SimpleNamespace(reason="max_output_tokens"),
            )
        )
    )
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
    assert turn.finish_reason == "length"


def test_complete_filters_and_aliases_settings():
    fake = _FakeClient(response=_response([_message_item("x")]))
    provider = OpenAIResponsesProvider(client=fake)
    provider.complete(
        model="m",
        messages=[{"role": "user", "content": "x"}],
        temperature=0.2,
        max_tokens=512,  # chat alias → max_output_tokens
        frequency_penalty=0.5,  # not a Responses param → dropped
        reasoning_effort="high",  # no effort knob in v1 → dropped
    )
    assert fake.kwargs["temperature"] == 0.2
    assert fake.kwargs["max_output_tokens"] == 512
    assert "max_tokens" not in fake.kwargs
    assert "frequency_penalty" not in fake.kwargs
    assert "reasoning_effort" not in fake.kwargs


def test_complete_passes_flat_tools():
    fake = _FakeClient(response=_response([_message_item("x")]))
    provider = OpenAIResponsesProvider(client=fake)
    provider.complete(
        model="m",
        messages=[{"role": "user", "content": "x"}],
        tools=[{"type": "function", "function": {"name": "f"}}],
    )
    assert fake.kwargs["tools"] == [{"type": "function", "name": "f"}]


def test_complete_parses_attr_style_sdk_objects():
    # The real SDK returns typed objects, not dicts — the parser must getattr its way in.
    response = SimpleNamespace(
        output=[
            SimpleNamespace(
                type="message",
                id="msg_1",
                role="assistant",
                content=[SimpleNamespace(type="output_text", text="hi", annotations=None)],
            ),
            SimpleNamespace(
                type="function_call",
                id="fc_1",
                call_id="call_1",
                name="f",
                arguments='{"a": 1}',
            ),
        ],
        status="completed",
        incomplete_details=None,
    )
    provider = OpenAIResponsesProvider(client=_FakeClient(response=response))
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
    assert turn.text == "hi"
    assert turn.tool_calls[0].id == "call_1"
    assert turn.tool_calls[0].arguments == {"a": 1}


# -- param-fix retries ---------------------------------------------------------------


def test_param_fix_drops_named_parameter():
    kwargs = {"model": "m", "input": [], "temperature": 0.2}
    fixed = _param_fix_retry(
        kwargs, Exception("Unsupported parameter: 'temperature' is not supported")
    )
    assert "temperature" not in fixed and kwargs["temperature"] == 0.2  # copy, not mutate


def test_param_fix_dotted_name_drops_top_level():
    fixed = _param_fix_retry(
        {"model": "m", "input": [], "reasoning": {"summary": "auto"}},
        Exception("Unsupported parameter: 'reasoning.summary'"),
    )
    assert "reasoning" not in fixed


def test_param_fix_reraises_unknown_errors():
    with pytest.raises(Exception, match="rate limit"):
        _param_fix_retry({"model": "m", "input": []}, Exception("rate limit exceeded"))


def test_complete_retries_dropping_rejected_params():
    # A non-reasoning model rejecting `reasoning` then `include` — both retried away.
    fake = _FakeClient(
        response=_response([_message_item("ok")]),
        errors=[
            Exception("Unsupported parameter: 'reasoning'"),
            Exception("Unsupported value: 'include[0]'"),
        ],
    )
    provider = OpenAIResponsesProvider(client=fake)
    turn = provider.complete(model="gpt-4.1", messages=[{"role": "user", "content": "x"}])
    assert turn.text == "ok"
    assert len(fake.calls) == 3
    assert "reasoning" not in fake.kwargs and "include" not in fake.kwargs


# -- stream() ------------------------------------------------------------------------


def test_stream_yields_deltas_then_final_turn_from_completed_event():
    final = _response(
        [
            _reasoning_item(["mull it over"], encrypted="blob"),
            _message_item("hello"),
        ]
    )
    events = [
        SimpleNamespace(type="response.created"),
        SimpleNamespace(type="response.reasoning_summary_text.delta", delta="mull "),
        SimpleNamespace(type="response.reasoning_summary_text.delta", delta="it over"),
        SimpleNamespace(type="response.output_text.delta", delta="hel"),
        SimpleNamespace(type="response.output_text.delta", delta="lo"),
        SimpleNamespace(type="response.completed", response=final),
    ]
    provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
    out = list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
    assert [c.reasoning_delta for c in out if c.reasoning_delta] == ["mull ", "it over"]
    assert [c.text_delta for c in out if c.text_delta] == ["hel", "lo"]
    turn = out[-1].turn
    assert turn.text == "hello" and turn.reasoning == "mull it over"
    assert turn.finish_reason == "stop"
    # Encrypted reasoning is replay-worthy even without function calls.
    assert [i["type"] for i in turn.extras["_openai"]["items"]] == [
        "reasoning",
        "message",
    ]


def test_stream_final_turn_carries_tool_calls_and_sidecar():
    final = _response(
        [
            _reasoning_item(["plan"], encrypted="blob"),
            _call_item("call_1", "f", '{"x": 1}'),
        ]
    )
    events = [SimpleNamespace(type="response.completed", response=final)]
    provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
    turn = list(
        provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
    )[-1].turn
    assert turn.finish_reason == "tool_calls"
    assert turn.tool_calls[0].arguments == {"x": 1}
    assert [i["type"] for i in turn.extras["_openai"]["items"]] == [
        "reasoning",
        "function_call",
    ]


def test_stream_rebuilds_turn_when_terminal_output_is_empty():
    """The subscription backend leaves `output` EMPTY on response.completed — items only
    ever stream. The turn must be rebuilt from the output_item.done events, or text and
    tool calls silently vanish (live bug: a turn produced deltas, then persisted empty)."""
    events = [
        SimpleNamespace(type="response.output_text.delta", delta="pong"),
        SimpleNamespace(
            type="response.output_item.done", item=_message_item("pong")
        ),
        SimpleNamespace(
            type="response.output_item.done", item=_call_item("call_1", "f", '{"x": 1}')
        ),
        SimpleNamespace(type="response.completed", response=_response([])),
    ]
    provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
    turn = list(
        provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
    )[-1].turn
    assert turn.text == "pong"
    assert turn.finish_reason == "tool_calls"
    assert turn.tool_calls[0].arguments == {"x": 1}


def test_stream_falls_back_to_deltas_when_no_items_repeat_anywhere():
    events = [
        SimpleNamespace(type="response.output_text.delta", delta="po"),
        SimpleNamespace(type="response.output_text.delta", delta="ng"),
        SimpleNamespace(type="response.completed", response=_response([])),
    ]
    provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
    turn = list(
        provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
    )[-1].turn
    assert turn.text == "pong" and turn.finish_reason == "stop"


def test_stream_without_terminal_event_keeps_accumulated_text():
    events = [SimpleNamespace(type="response.output_text.delta", delta="partial")]
    provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
    turn = list(
        provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
    )[-1].turn
    assert turn.text == "partial" and turn.finish_reason is None
    assert turn.usage is None  # nothing terminal arrived — no usage to invent


def test_stream_terminal_event_carries_usage():
    # OPE-101 streaming path: usage rides the terminal `response.completed` event's full
    # response object, which the stream parses whole — same extraction as complete().
    final = _response([_message_item("done")])
    final.usage = SimpleNamespace(
        input_tokens=1430,
        output_tokens=65,
        input_tokens_details=SimpleNamespace(cached_tokens=1408),
    )
    events = [
        SimpleNamespace(type="response.output_text.delta", delta="done"),
        SimpleNamespace(type="response.completed", response=final),
    ]
    provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
    turn = list(
        provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
    )[-1].turn
    assert turn.usage is not None
    assert (turn.usage.input, turn.usage.output, turn.usage.cache_read) == (22, 65, 1408)


def test_stream_requests_stream_flag():
    fake = _FakeClient(events=[])
    provider = OpenAIResponsesProvider(client=fake)
    list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
    assert fake.kwargs["stream"] is True


# -- round trip ----------------------------------------------------------------------


def test_sidecar_round_trip_replays_what_complete_stored():
    """A tool loop: turn 1's sidecar items must be exactly what turn 2's request replays."""
    items = [
        _reasoning_item(["plan"], encrypted="blob"),
        _call_item("call_1", "f", "{}"),
    ]
    provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
    turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])

    # The engine persists canonical fields + extras (engine._assistant_message):
    assistant_message = {
        "role": "assistant",
        "content": turn.text or "",
        "tool_calls": [
            {
                "id": tc.id,
                "type": "function",
                "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
            }
            for tc in turn.tool_calls
        ],
        **turn.extras,
    }
    fake2 = _FakeClient(response=_response([_message_item("done")]))
    provider2 = OpenAIResponsesProvider(client=fake2)
    provider2.complete(
        model="m",
        messages=[
            {"role": "user", "content": "go"},
            assistant_message,
            {"role": "tool", "tool_call_id": "call_1", "content": "ok"},
        ],
    )
    sent = fake2.kwargs["input"]
    assert sent[1:3] == items  # replayed verbatim, reasoning first
    assert sent[3] == {
        "type": "function_call_output",
        "call_id": "call_1",
        "output": "ok",
    }


def test_ensure_client_without_key_raises(monkeypatch):
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    with pytest.raises(RuntimeError, match="No model API key"):
        OpenAIResponsesProvider()._ensure_client()


# -- registry routing ----------------------------------------------------------------


def test_registry_routes_blank_endpoint_to_responses():
    from coworker.providers import OpenAIProvider
    from coworker.providers.registry import build_provider_client

    assert isinstance(
        build_provider_client("openai", {}, None), OpenAIResponsesProvider
    )
    assert isinstance(
        build_provider_client("openai", {"base_url": "   "}, None),
        OpenAIResponsesProvider,
    )
    # A custom endpoint (Azure, vLLM, any compat gateway) keeps Chat Completions…
    custom = build_provider_client(
        "openai", {"base_url": "https://my.azure.example/openai/v1"}, None
    )
    assert isinstance(custom, OpenAIProvider)
    # …and so do Ollama and every compat vendor (their own descriptors).
    assert isinstance(build_provider_client("ollama", {}, None), OpenAIProvider)
    assert isinstance(
        build_provider_client("deepseek", {"api_key": "sk-x"}, None), OpenAIProvider
    )
