"""Anthropic provider — native Claude Messages API.

The runtime's canonical message format is OpenAI-shaped (that is what the engine builds and
persists), so this module is mostly a pair of pure converters: OpenAI-style messages → Anthropic
`messages` + `system`, and OpenAI function schemas → Anthropic `tools`. The Messages API differs
from chat.completions in ways the converters must absorb:

- `system` is a top-level param, not a message role.
- Assistant tool calls are `tool_use` content blocks (input is a dict, not a JSON string).
- Tool results are `tool_result` blocks that must ALL land in the single next user message —
  N consecutive `role:"tool"` messages collapse into one user message here.
- `max_tokens` is required.
"""

from __future__ import annotations

import json
import re
from typing import Any, Optional

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

# Required by the Messages API; a ceiling, not a spend target.
DEFAULT_MAX_TOKENS = 16000

# Anthropic stop_reason → the engine's OpenAI-shaped finish_reason vocabulary.
_STOP_REASON_MAP = {
    "end_turn": "stop",
    "tool_use": "tool_calls",
    "max_tokens": "length",
    "stop_sequence": "stop",
    "refusal": "stop",
    "pause_turn": "stop",
}

# Settings the Messages API accepts; everything else (frequency_penalty, …) is dropped.
_SETTINGS_WHITELIST = {
    "max_tokens",
    "temperature",
    "top_p",
    "top_k",
    "stop_sequences",
    "metadata",
}

_DATA_URL_RE = re.compile(
    r"^data:(image/[a-z0-9.+-]+);base64,(.+)$", re.IGNORECASE | re.DOTALL
)

_PDF_DATA_URL_RE = re.compile(
    r"^data:application/pdf;base64,(.+)$", re.IGNORECASE | re.DOTALL
)


def resolve_api_key(secrets: Any = None) -> Optional[str]:
    """Resolve the Anthropic API key: env `ANTHROPIC_API_KEY` first, else the SecretStore
    `provider:anthropic` profile (`{api_key}`). Same contract as the OpenAI resolver: the
    Tauri-launched sidecar does not inherit the shell env, so Settings-entered keys must work.
    """
    import os

    key = os.environ.get("ANTHROPIC_API_KEY")
    if key:
        return key
    if secrets is not None:
        profile = secrets.get("provider:anthropic") or {}
        return profile.get("api_key") or None
    return None


def _parse_args(raw: Any) -> dict[str, Any]:
    """Tool-call arguments: dict passthrough, JSON string parse, `{"_raw": …}` fallback."""
    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):
        return {"_raw": raw}


def _image_block(url: str) -> Optional[dict[str, Any]]:
    """An OpenAI `image_url` part → an Anthropic image block. Attachments are always data URLs
    (attachments.py); plain http(s) URLs map to a url source. Anything else → None."""
    match = _DATA_URL_RE.match(url or "")
    if match:
        return {
            "type": "image",
            "source": {
                "type": "base64",
                "media_type": match.group(1).lower(),
                "data": match.group(2),
            },
        }
    if (url or "").startswith(("http://", "https://")):
        return {"type": "image", "source": {"type": "url", "url": url}}
    return None


def _document_block(part: dict[str, Any]) -> Optional[dict[str, Any]]:
    """An OpenAI `file` part (PDF data URL, attachments.py) → an Anthropic document block."""
    file = part.get("file") or {}
    match = _PDF_DATA_URL_RE.match(file.get("file_data") or "")
    if not match:
        return None
    block: dict[str, Any] = {
        "type": "document",
        "source": {
            "type": "base64",
            "media_type": "application/pdf",
            "data": match.group(1),
        },
    }
    name = file.get("filename")
    if name:
        block["title"] = str(name)
    return block


def _user_blocks(content: Any) -> list[dict[str, Any]]:
    """User content (str or OpenAI parts list) → Anthropic content blocks."""
    if isinstance(content, str):
        return [{"type": "text", "text": content}] if content else []
    blocks: list[dict[str, Any]] = []
    for part in content or []:
        kind = part.get("type") if isinstance(part, dict) else None
        if kind == "text":
            text = part.get("text") or ""
            if text:
                blocks.append({"type": "text", "text": text})
        elif kind == "image_url":
            url = (part.get("image_url") or {}).get("url") or ""
            block = _image_block(url)
            blocks.append(
                block
                if block
                else {"type": "text", "text": "[unsupported image attachment]"}
            )
        elif kind == "file":
            block = _document_block(part)
            blocks.append(
                block
                if block
                else {"type": "text", "text": "[unsupported file attachment]"}
            )
    return blocks


def convert_messages(
    messages: list[dict[str, Any]],
) -> tuple[Optional[str], list[dict[str, Any]]]:
    """OpenAI-shaped history → (`system`, Anthropic `messages`).

    Leading system messages become the `system` param. Consecutive same-role outputs are folded
    into one message — this is what collapses a run of `role:"tool"` results (one per parallel
    call) into the single user message Anthropic requires, with any steering user text after.
    """
    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

    converted: list[dict[str, Any]] = []
    for message in messages[index:]:
        role = message.get("role")
        if role == "system":
            # Defensive: a stray mid-thread system message rides as marked user text.
            text = message.get("content") or ""
            if text:
                converted.append(
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": f"<system>\n{text}\n</system>"}
                        ],
                    }
                )
        elif role == "user":
            blocks = _user_blocks(message.get("content"))
            if blocks:
                converted.append({"role": "user", "content": blocks})
        elif role == "assistant":
            blocks = []
            text = message.get("content")
            if isinstance(text, str) and text:
                blocks.append({"type": "text", "text": text})
            for call in message.get("tool_calls") or []:
                function = call.get("function") or {}
                blocks.append(
                    {
                        "type": "tool_use",
                        "id": call.get("id") or "",
                        "name": function.get("name") or "",
                        "input": _parse_args(function.get("arguments")),
                    }
                )
            if blocks:
                converted.append({"role": "assistant", "content": blocks})
        elif role == "tool":
            converted.append(
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "tool_result",
                            "tool_use_id": message.get("tool_call_id") or "",
                            "content": str(message.get("content") or ""),
                        }
                    ],
                }
            )

    folded: list[dict[str, Any]] = []
    for message in converted:
        if folded and folded[-1]["role"] == message["role"]:
            folded[-1]["content"].extend(message["content"])
        else:
            folded.append(message)

    if not folded:
        raise ValueError("no convertible messages for the Anthropic Messages API")
    if folded[0]["role"] != "user":
        folded.insert(
            0, {"role": "user", "content": [{"type": "text", "text": "(continued)"}]}
        )

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


def convert_tools(tools: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]:
    """OpenAI function schemas → Anthropic tool definitions. Missing description is omitted;
    missing/typeless parameters become an empty object schema (Anthropic requires one).
    """
    converted = []
    for tool in tools or []:
        function = tool.get("function") or {}
        entry: dict[str, Any] = {"name": function.get("name") or ""}
        if function.get("description"):
            entry["description"] = function["description"]
        parameters = function.get("parameters")
        if not isinstance(parameters, dict) or not parameters.get("type"):
            parameters = {"type": "object", "properties": {}}
        entry["input_schema"] = parameters
        converted.append(entry)
    return converted


class AnthropicProvider(ProviderClient):
    def __init__(
        self,
        client: Any = None,
        *,
        default_model: str = "claude-sonnet-4-6",
        api_key: Optional[str] = None,
        secrets: Any = None,
    ):
        # Mirrors OpenAIProvider: the SDK client is built lazily so engines can be assembled
        # before any key exists; the key resolves at call time (explicit → env → SecretStore).
        # Tests inject a `client` directly.
        self._client = client
        self._api_key = api_key
        self._secrets = secrets
        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 Anthropic.
            from anthropic import Anthropic

            key = self._api_key or resolve_api_key(self._secrets)
            if not key:
                raise RuntimeError(
                    "No Anthropic API key configured. Set ANTHROPIC_API_KEY in the environment, "
                    "or add your key in Manage → Configure Models."
                )
            self._client = Anthropic(api_key=key)
        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]:
        system, converted = convert_messages(messages)
        if "stop" in settings and "stop_sequences" not in settings:
            stop = settings["stop"]
            settings["stop_sequences"] = [stop] if isinstance(stop, str) else list(stop)
        filtered = {k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST}
        filtered.setdefault("max_tokens", DEFAULT_MAX_TOKENS)
        kwargs: dict[str, Any] = {"model": model, "messages": converted, **filtered}
        if system:
            kwargs["system"] = system
        if tools:
            kwargs["tools"] = convert_tools(tools)
        return 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._ensure_client().messages.create(**kwargs)

        text_parts: list[str] = []
        tool_calls: list[ToolCall] = []
        for block in getattr(response, "content", None) or []:
            kind = getattr(block, "type", None)
            if kind == "text":
                text_parts.append(getattr(block, "text", "") or "")
            elif kind == "tool_use":
                tool_calls.append(
                    ToolCall(
                        id=getattr(block, "id", "") or "",
                        name=getattr(block, "name", "") or "",
                        arguments=dict(getattr(block, "input", None) or {}),
                    )
                )
        stop_reason = getattr(response, "stop_reason", None)
        return AssistantTurn(
            text="".join(text_parts) or None,
            tool_calls=tool_calls,
            finish_reason=_STOP_REASON_MAP.get(stop_reason, stop_reason),
            raw=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
        client = self._ensure_client()

        text_parts: list[str] = []
        tool_accum: dict[int, dict[str, str]] = {}
        stop_reason = None

        for event in client.messages.create(**kwargs):
            kind = getattr(event, "type", None)
            if kind == "content_block_start":
                block = getattr(event, "content_block", None)
                if getattr(block, "type", None) == "tool_use":
                    tool_accum[getattr(event, "index", 0)] = {
                        "id": getattr(block, "id", "") or "",
                        "name": getattr(block, "name", "") or "",
                        "json": "",
                    }
            elif kind == "content_block_delta":
                delta = getattr(event, "delta", None)
                delta_kind = getattr(delta, "type", None)
                if delta_kind == "text_delta":
                    text = getattr(delta, "text", "") or ""
                    if text:
                        text_parts.append(text)
                        yield StreamChunk(text_delta=text)
                elif delta_kind == "input_json_delta":
                    acc = tool_accum.get(getattr(event, "index", 0))
                    if acc is not None:
                        acc["json"] += getattr(delta, "partial_json", "") or ""
                # thinking/signature deltas are ignored
            elif kind == "message_delta":
                reason = getattr(getattr(event, "delta", None), "stop_reason", None)
                if reason:
                    stop_reason = reason

        tool_calls = []
        for index in sorted(tool_accum):
            acc = tool_accum[index]
            tool_calls.append(
                ToolCall(
                    id=acc["id"], name=acc["name"], arguments=_parse_args(acc["json"])
                )
            )

        yield StreamChunk(
            turn=AssistantTurn(
                text="".join(text_parts) or None,
                tool_calls=tool_calls,
                finish_reason=_STOP_REASON_MAP.get(stop_reason, stop_reason),
            )
        )
