"""OpenAI Responses provider — native and compatible models via `/responses`.

Chat Completions rejects function tools combined with any `reasoning_effort` other than
`none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI
models (see `openai_provider._pin_reasoning_effort`). This provider is the Responses path:
reasoning + tools at real effort levels, streamed reasoning summaries (→ the same
`reasoning_delta` / `AssistantTurn.reasoning` plumbing the GUI already renders), and
chain-of-thought continuity across tool round-trips via `store: false` +
`include: ["reasoning.encrypted_content"]` — nothing retained server-side.

Routing: the `openai` provider entry with NO custom base_url builds this class. Most custom
endpoints (Azure, vLLM, and the existing compat vendors) keep the Chat Completions
`OpenAIProvider`; vendors that explicitly implement the Responses wire can opt into this
class with their own base URL (registry.py).

Like the other native providers, this is mostly a pair of pure converters from the
canonical OpenAI-chat-shaped history to Responses `input` items. What the converters
must absorb:

- The system prompt is the `instructions` request field, not a message role.
- Assistant tool calls are top-level `function_call` items; tool results are
  `function_call_output` items paired by `call_id` (ids only need to pair up, so foreign
  `toolu_…` ids from a mid-conversation provider switch are fine).
- Tool schemas are FLAT (`{"type": "function", "name", …}` — no nested `function` key).
- Reasoning continuity: the raw output items (reasoning item with `encrypted_content`,
  `function_call` items with their ids) ride the canonical assistant message as the
  `_openai` sidecar (see providers/base.py). Present → replayed verbatim for exact CoT
  continuity; absent (history from another provider) → items are synthesized from the
  canonical fields. Reasoning items WITHOUT `encrypted_content` never enter the sidecar:
  with `store: false` the server can't resolve them and would reject the replay.
"""

from __future__ import annotations

import json
import re
from typing import Any, Optional

from .base import (
    AssistantTurn,
    ModelCapabilities,
    ProviderClient,
    StreamChunk,
    TokenUsage,
    ToolCall,
)
from .capabilities import capabilities_for
from .openai_provider import resolve_api_key

# Request params passed through from model settings; everything else (frequency_penalty,
# reasoning_effort — no effort knob in v1, the server default rides) is dropped.
_SETTINGS_WHITELIST = {
    "temperature",
    "top_p",
    "max_output_tokens",
    "tool_choice",
    "parallel_tool_calls",
}

# "Unsupported parameter: 'temperature' is not supported with this model." — reasoning
# models reject sampling params; non-reasoning models reject `reasoning`/`include`. The
# server names exactly one offender per error, so each retry drops exactly that.
_UNSUPPORTED_PARAM = re.compile(r"unsupported (?:parameter|value)s?:?\s*'([^']+)'")


def _param_fix_retry(kwargs: dict[str, Any], exc: Exception) -> dict[str, Any]:
    """Kwargs for the one retry an unsupported-parameter error earns, or re-raise.

    Same contract as the Chat Completions retries: fix exactly what the server named.
    A dotted name (`reasoning.summary`) drops its top-level param.
    """
    match = _UNSUPPORTED_PARAM.search(str(exc).lower())
    if match:
        param = match.group(1).split(".", 1)[0].split("[", 1)[0]
        if param in kwargs and param not in ("model", "input"):
            fixed = dict(kwargs)
            del fixed[param]
            return fixed
    raise exc


def _user_content(content: Any) -> Any:
    """User content (str or OpenAI chat parts) → Responses content (str or input parts)."""
    if isinstance(content, str):
        return content
    parts: list[dict[str, Any]] = []
    for part in content or []:
        kind = part.get("type") if isinstance(part, dict) else None
        if kind == "text":
            parts.append({"type": "input_text", "text": part.get("text") or ""})
        elif kind == "image_url":
            url = (part.get("image_url") or {}).get("url") or ""
            parts.append({"type": "input_image", "image_url": url})
        elif kind == "file":
            file = part.get("file") or {}
            entry: dict[str, Any] = {"type": "input_file"}
            if file.get("filename"):
                entry["filename"] = file["filename"]
            if file.get("file_data"):
                entry["file_data"] = file["file_data"]
            parts.append(entry)
    return parts


def _synthesized_items(message: dict[str, Any]) -> list[dict[str, Any]]:
    """An assistant message WITHOUT a usable `_openai` sidecar (history produced by another
    provider before a switch) → items rebuilt from the canonical fields."""
    items: list[dict[str, Any]] = []
    text = message.get("content")
    if isinstance(text, str) and text:
        items.append({"role": "assistant", "content": text})
    for call in message.get("tool_calls") or []:
        function = call.get("function") or {}
        arguments = function.get("arguments")
        if not isinstance(arguments, str):
            arguments = json.dumps(arguments or {})
        items.append(
            {
                "type": "function_call",
                "call_id": call.get("id") or "",
                "name": function.get("name") or "",
                "arguments": arguments,
            }
        )
    return items


def convert_messages(
    messages: list[dict[str, Any]],
) -> tuple[Optional[str], list[dict[str, Any]]]:
    """Canonical OpenAI-chat history → (`instructions`, Responses `input` items).

    Leading system messages join into `instructions`; a stray mid-thread system message
    rides as a system message item. Assistant messages replay their `_openai` sidecar
    verbatim when present (exact CoT continuity), else synthesize from canonical fields.
    """
    system_parts: list[str] = []
    index = 0
    while index < len(messages) and messages[index].get("role") == "system":
        content = messages[index].get("content")
        if isinstance(content, str) and content:
            system_parts.append(content)
        index += 1

    items: list[dict[str, Any]] = []
    for message in messages[index:]:
        role = message.get("role")
        if role == "system":
            text = message.get("content") or ""
            if text:
                items.append({"role": "system", "content": text})
        elif role == "user":
            content = _user_content(message.get("content"))
            if content:
                items.append({"role": "user", "content": content})
        elif role == "assistant":
            sidecar = message.get("_openai") or {}
            replay = sidecar.get("items") or []
            if replay:
                items.extend(replay)
            else:
                items.extend(_synthesized_items(message))
        elif role == "tool":
            content = message.get("content")
            items.append(
                {
                    "type": "function_call_output",
                    "call_id": message.get("tool_call_id") or "",
                    "output": content if isinstance(content, str) else str(content or ""),
                }
            )

    return ("\n\n".join(system_parts) or None), items


def convert_tools(tools: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]:
    """OpenAI chat function schemas → Responses FLAT tool entries (no nested `function`)."""
    converted: list[dict[str, Any]] = []
    for tool in tools or []:
        function = (tool or {}).get("function") or {}
        name = function.get("name")
        if not name:
            continue
        entry: dict[str, Any] = {"type": "function", "name": name}
        if function.get("description"):
            entry["description"] = function["description"]
        if function.get("parameters") is not None:
            entry["parameters"] = function["parameters"]
        converted.append(entry)
    return converted


def _dump(value: Any) -> Any:
    """An output item (SDK model, dict, or test namespace) → plain jsonl-safe data."""
    if isinstance(value, dict):
        return {k: _dump(v) for k, v in value.items() if v is not None}
    if isinstance(value, (list, tuple)):
        return [_dump(v) for v in value]
    dump = getattr(value, "model_dump", None)
    if callable(dump):
        return dump(exclude_none=True)
    if hasattr(value, "__dict__"):  # SimpleNamespace fakes in tests
        return {k: _dump(v) for k, v in vars(value).items() if v is not None}
    return value


def _parse_arguments(raw: Any) -> dict[str, Any]:
    if isinstance(raw, dict):
        return raw
    if not raw:
        return {}
    try:
        parsed = json.loads(raw)
        return parsed if isinstance(parsed, dict) else {"_raw": raw}
    except (TypeError, json.JSONDecodeError):
        # Surface unparseable arguments rather than dropping the call; the engine
        # can return a tool-error so the model corrects itself.
        return {"_raw": raw}


def _sidecar_extras(items: list[dict[str, Any]]) -> dict[str, Any]:
    """Output items → the `_openai` sidecar, or {} when replay would add nothing.

    Reasoning items without `encrypted_content` are dropped: under `store: false` the
    server can't resolve them by id and rejects the replay. The sidecar is only worth
    persisting when something beyond plain answer text needs continuity.
    """
    kept = [
        item
        for item in items
        if item.get("type") != "reasoning" or item.get("encrypted_content")
    ]
    if any(item.get("type") in ("reasoning", "function_call") for item in kept):
        return {"_openai": {"items": kept}}
    return {}


def _usage_from(usage: Any) -> Optional[TokenUsage]:
    """Responses-API usage → normalized counts (OPE-101). `input_tokens` INCLUDES the
    cached share, so fresh input = input_tokens − cached_tokens (the same convention as
    the Chat Completions and Anthropic adapters); `output_tokens` already includes
    reasoning tokens (billed as output). Defensive reads throughout — compat/older
    servers may omit `input_tokens_details`."""
    if usage is None:
        return None
    prompt = int(getattr(usage, "input_tokens", 0) or 0)
    details = getattr(usage, "input_tokens_details", None)
    cached = int(getattr(details, "cached_tokens", 0) or 0)
    return TokenUsage(
        input=max(prompt - cached, 0),
        output=int(getattr(usage, "output_tokens", 0) or 0),
        cache_read=cached,
    )


def _parse_response(response: Any) -> AssistantTurn:
    """One Responses result → an AssistantTurn (+ `_openai` extras)."""
    items = [_dump(item) for item in getattr(response, "output", None) or []]
    texts: list[str] = []
    summaries: list[str] = []
    tool_calls: list[ToolCall] = []
    for item in items:
        kind = item.get("type")
        if kind == "message" or (kind is None and "content" in item):
            content = item.get("content")
            if isinstance(content, str):
                texts.append(content)
            else:
                for part in content or []:
                    if part.get("type") == "output_text" and part.get("text"):
                        texts.append(part["text"])
        elif kind == "reasoning":
            for part in item.get("summary") or []:
                text = part.get("text") if isinstance(part, dict) else part
                if text:
                    summaries.append(text)
        elif kind == "function_call":
            tool_calls.append(
                ToolCall(
                    id=item.get("call_id") or item.get("id") or "",
                    name=item.get("name") or "",
                    arguments=_parse_arguments(item.get("arguments")),
                )
            )

    incomplete = _dump(getattr(response, "incomplete_details", None)) or {}
    if tool_calls:
        finish = "tool_calls"
    elif incomplete.get("reason") == "max_output_tokens":
        finish = "length"
    else:
        finish = "stop"

    return AssistantTurn(
        text="".join(texts) or None,
        tool_calls=tool_calls,
        finish_reason=finish,
        raw=response,
        reasoning="".join(summaries) or None,
        extras=_sidecar_extras(items),
        usage=_usage_from(getattr(response, "usage", None)),
    )


class OpenAIResponsesProvider(ProviderClient):
    def __init__(
        self,
        client: Any = None,
        *,
        default_model: str = "gpt-5.6-sol",
        api_key: Optional[str] = None,
        secrets: Any = None,
        base_url: Optional[str] = None,
        reasoning_summary: bool = True,
    ):
        # Same deferred-client contract as OpenAIProvider: built lazily so an engine can be
        # assembled before any key exists; key resolves at call time (explicit → env →
        # SecretStore). Tests inject a `client` directly. `base_url` is opt-in: stock OpenAI
        # leaves it unset, while Responses-compatible vendors can supply their own endpoint.
        self._client = client
        self._api_key = api_key
        self._secrets = secrets
        self._base_url = (base_url or "").strip().rstrip("/") or None
        if not isinstance(reasoning_summary, bool):
            raise TypeError("reasoning_summary must be a bool")
        self._reasoning_summary = reasoning_summary
        self.default_model = default_model

    def _ensure_client(self) -> Any:
        if self._client is None:
            # Lazy import so the SDK is only required when actually talking to OpenAI.
            from openai import OpenAI

            key = self._api_key or resolve_api_key(self._secrets)
            if not key:
                raise RuntimeError(
                    "No model API key configured. Set OPENAI_API_KEY in the environment, "
                    "or add your key in Manage → Settings."
                )
            kwargs = {"api_key": key}
            if self._base_url:
                kwargs["base_url"] = self._base_url
            self._client = OpenAI(**kwargs)
        return self._client

    def _request_kwargs(
        self,
        *,
        model: str,
        messages: list[dict[str, Any]],
        tools: Optional[list[dict[str, Any]]],
        settings: dict[str, Any],
    ) -> dict[str, Any]:
        instructions, items = convert_messages(messages)
        if "max_tokens" in settings and "max_output_tokens" not in settings:
            settings = {**settings, "max_output_tokens": settings["max_tokens"]}
        kwargs: dict[str, Any] = {
            "model": model,
            "input": items,
            # Stateless: nothing retained server-side; the encrypted reasoning rides the
            # `_openai` sidecar instead, and summaries feed the GUI's thinking display.
            "store": False,
            "include": ["reasoning.encrypted_content"],
            **{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST},
        }
        if self._reasoning_summary:
            kwargs["reasoning"] = {"summary": "auto"}
        if instructions:
            kwargs["instructions"] = instructions
        if tools:
            converted = convert_tools(tools)
            if converted:
                kwargs["tools"] = converted
        return kwargs

    def _create(self, client: Any, kwargs: dict[str, Any]) -> Any:
        # Up to three param-fix retries: sampling params, `reasoning`, and `include` can
        # each need dropping depending on the model (reasoning vs not).
        for _ in range(3):
            try:
                return client.responses.create(**kwargs)
            except Exception as exc:
                kwargs = _param_fix_retry(kwargs, exc)
        return client.responses.create(**kwargs)

    def complete(
        self,
        *,
        model: str,
        messages: list[dict[str, Any]],
        tools: Optional[list[dict[str, Any]]] = None,
        **settings: Any,
    ) -> AssistantTurn:
        kwargs = self._request_kwargs(
            model=model, messages=messages, tools=tools, settings=settings
        )
        response = self._create(self._ensure_client(), kwargs)
        return _parse_response(response)

    def capabilities(self, model: str) -> ModelCapabilities:
        return capabilities_for(model)

    def stream(
        self,
        *,
        model: str,
        messages: list[dict[str, Any]],
        tools: Optional[list[dict[str, Any]]] = None,
        **settings: Any,
    ):
        kwargs = self._request_kwargs(
            model=model, messages=messages, tools=tools, settings=settings
        )
        kwargs["stream"] = True
        events = self._create(self._ensure_client(), kwargs)

        text_parts: list[str] = []
        reasoning_parts: list[str] = []
        done_items: list[Any] = []
        final: Optional[Any] = None
        for event in events:
            kind = getattr(event, "type", None)
            if kind == "response.output_text.delta":
                delta = getattr(event, "delta", None)
                if delta:
                    text_parts.append(delta)
                    yield StreamChunk(text_delta=delta)
            elif kind == "response.reasoning_summary_text.delta":
                delta = getattr(event, "delta", None)
                if delta:
                    reasoning_parts.append(delta)
                    yield StreamChunk(reasoning_delta=delta)
            elif kind == "response.output_item.done":
                item = getattr(event, "item", None)
                if item is not None:
                    done_items.append(item)
            elif kind in ("response.completed", "response.incomplete", "response.failed"):
                final = getattr(event, "response", None)

        if final is not None:
            # The terminal event carries the full response — parse it whole so tool
            # calls, finish reason, and the `_openai` sidecar come from one place.
            # Some Responses-compatible backends (the subscription backend) leave the
            # terminal response's `output` EMPTY — the items only ever stream — so
            # graft the streamed output_item.done items back on before parsing, or a
            # turn's text and tool calls silently vanish.
            if not (getattr(final, "output", None) or []) and done_items:
                try:
                    final.output = done_items
                except Exception:
                    pass
            turn = _parse_response(final)
            if turn.text is None and not turn.tool_calls and text_parts:
                turn.text = "".join(text_parts)
            yield StreamChunk(turn=turn)
        else:
            yield StreamChunk(
                turn=AssistantTurn(
                    text="".join(text_parts) or None,
                    reasoning="".join(reasoning_parts) or None,
                )
            )
