"""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.
- Extended thinking (opt-in via the provider profile's `thinking_budget` field): responses
  carry `thinking`/`redacted_thinking` blocks that MUST be replayed verbatim (signatures
  and all) ahead of the same turn's tool_use blocks when returning tool results — they ride
  the canonical assistant message as the `_anthropic` sidecar and are reattached here. The
  thinking text also lands on `AssistantTurn.reasoning` for display.
"""

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


def _usage_from(usage: Any) -> Optional[TokenUsage]:
    """Messages-API usage object → normalized counts (input_tokens excludes cache)."""
    if usage is None:
        return None
    return TokenUsage(
        input=int(getattr(usage, "input_tokens", 0) or 0),
        output=int(getattr(usage, "output_tokens", 0) or 0),
        cache_read=int(getattr(usage, "cache_read_input_tokens", 0) or 0),
        cache_write=int(getattr(usage, "cache_creation_input_tokens", 0) or 0),
    )

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

# Extended thinking is ON by default (owner call 2026-07-23: no user-facing setting —
# most users wouldn't know what a budget is; a per-turn composer control is future work).
# The provider profile's `thinking_budget` remains a hidden override: a number replaces
# the default, 0 disables thinking (where the model allows disabling).
DEFAULT_THINKING_BUDGET = 8192

# API drift (2026): thinking config is MODEL-FAMILY specific.
# - Pre-4.6 models (Haiku 4.5, Sonnet 4.5, Opus 4.5 and older): thinking needs
#   {"type": "enabled", "budget_tokens": N}.
# - 4.6+ and the Claude 5 family (Fable/Mythos 5, Opus 4.8/4.7, Sonnet 5, the 4.6 pair):
#   budget_tokens is deprecated/REMOVED (hard 400 on 4.7+: '"thinking.type.enabled" is
#   not supported for this model') — use {"type": "adaptive"}. Fable 5 thinking is
#   always on and can't be disabled. `display: "summarized"` is required to get trace
#   text on 4.7+ (default "omitted" streams thinking blocks with EMPTY text).
_BUDGET_THINKING_PREFIXES = (
    "claude-haiku-4-5",
    "claude-sonnet-4-5",
    "claude-opus-4-5",
    "claude-opus-4-1",
    "claude-opus-4-0",
    "claude-sonnet-4-0",
    "claude-3",
    "claude-2",
)


def _uses_budget_thinking(model: str) -> bool:
    return model.startswith(_BUDGET_THINKING_PREFIXES)


# Fable/Mythos 5 run safety classifiers that can decline benign-adjacent requests
# (HTTP 200, stop_reason "refusal", empty or partial content). Recommended posture is
# the server-side fallback: the API re-serves the declined request on Opus 4.8 within
# the same call. Beta header + param, beta messages endpoint.
_FALLBACK_BETA = "server-side-fallback-2026-06-01"
_FALLBACK_MODEL = "claude-opus-4-8"


def _needs_refusal_fallback(model: str) -> bool:
    return model.startswith(("claude-fable", "claude-mythos"))


def _raise_on_refusal(stop_reason: Any, raw: Any) -> None:
    """A refusal that survived the fallback chain becomes a normal provider error —
    the engine persists it as an error notice with Retry, instead of a silent blank."""
    if stop_reason != "refusal":
        return
    details = getattr(raw, "stop_details", None)
    category = getattr(details, "category", None)
    suffix = f" (category: {category})" if category else ""
    raise RuntimeError(
        "Claude's safety filter declined this request"
        + suffix
        + " — try rephrasing, or switch model and press Retry."
    )

# 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",
    "thinking",
}

# Sampling knobs the API rejects alongside extended thinking (temperature must stay 1).
_THINKING_INCOMPATIBLE = ("temperature", "top_p", "top_k")

_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 = []
            # Replay thinking/redacted_thinking blocks VERBATIM, ahead of the turn's own
            # blocks — required whenever the turn's tool calls are being answered.
            blocks.extend((message.get("_anthropic") or {}).get("blocks") or [])
            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


def _add_cache_breakpoints(kwargs: dict[str, Any]) -> None:
    """Opt the request into prompt caching (5-minute ephemeral, prefix-matched).

    Two breakpoints, the standard agent-loop shape:
    - last system block — caches tools + system together (tools render first);
    - last content block of the final message — caches the whole conversation
      prefix, so each request re-reads the previous turns' cache and writes only
      the new tail (append-only history keeps the prefix byte-identical).

    Outbound-only: the canonical history never carries `cache_control` (the final
    message's blocks are freshly built by convert_messages — thinking replays sit
    in earlier assistant turns — and the marked block is copied, not mutated).
    Prefixes under the model's cacheable minimum silently don't cache; reads bill
    ~0.1x and show up as `cache_read_input_tokens` (the metering's cache_read).
    """
    marker = {"type": "ephemeral"}
    system = kwargs.get("system")
    if isinstance(system, str) and system:
        kwargs["system"] = [{"type": "text", "text": system, "cache_control": marker}]
    messages = kwargs.get("messages") or []
    if messages:
        content = messages[-1].get("content")
        if isinstance(content, list) and content:
            content[-1] = {**content[-1], "cache_control": marker}


def _reasoning_text(thinking_blocks: list[dict[str, Any]]) -> Optional[str]:
    """Display text for the GUI's disclosure — thinking text only (redacted stays opaque)."""
    text = "".join(
        b.get("thinking", "") for b in thinking_blocks if b.get("type") == "thinking"
    )
    return text or None


def _thinking_extras(thinking_blocks: list[dict[str, Any]]) -> dict[str, Any]:
    """Raw blocks → the `_anthropic` sidecar convert_messages replays (empty when none)."""
    return {"_anthropic": {"blocks": thinking_blocks}} if thinking_blocks else {}


class AnthropicProvider(ProviderClient):
    def __init__(
        self,
        client: Any = None,
        *,
        default_model: str = "claude-sonnet-4-6",
        api_key: Optional[str] = None,
        secrets: Any = None,
        thinking_budget: Optional[int] = 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. `thinking_budget` (tokens, from the provider
        # profile's optional field) opts every request into extended thinking.
        self._client = client
        self._api_key = api_key
        self._secrets = secrets
        self.default_model = default_model
        self.thinking_budget = thinking_budget or 0

    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}
        if self.thinking_budget > 0 and "thinking" not in filtered:
            if _uses_budget_thinking(model):
                filtered["thinking"] = {
                    "type": "enabled",
                    "budget_tokens": self.thinking_budget,
                }
            else:
                # 4.6+/Claude 5 family: adaptive only (budget_tokens 400s on 4.7+);
                # display opt-in or the trace text arrives empty.
                filtered["thinking"] = {"type": "adaptive", "display": "summarized"}
        thinking = filtered.get("thinking") or {}
        if thinking.get("type") == "enabled":
            # Budget must fit under max_tokens.
            budget = int(thinking.get("budget_tokens") or 0)
            floor = max(DEFAULT_MAX_TOKENS, budget + 4096)
            if int(filtered.get("max_tokens") or 0) <= budget:
                filtered["max_tokens"] = floor
        if thinking.get("type") in ("enabled", "adaptive"):
            # Sampling knobs are rejected alongside thinking (and removed outright on 4.7+).
            for key in _THINKING_INCOMPATIBLE:
                filtered.pop(key, None)
        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)
        _add_cache_breakpoints(kwargs)
        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
        )
        client = self._ensure_client()
        if _needs_refusal_fallback(model):
            response = client.beta.messages.create(
                **kwargs,
                betas=[_FALLBACK_BETA],
                fallbacks=[{"model": _FALLBACK_MODEL}],
            )
        else:
            response = client.messages.create(**kwargs)

        text_parts: list[str] = []
        tool_calls: list[ToolCall] = []
        thinking_blocks: list[dict[str, Any]] = []
        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 {}),
                    )
                )
            elif kind == "thinking":
                thinking_blocks.append(
                    {
                        "type": "thinking",
                        "thinking": getattr(block, "thinking", "") or "",
                        "signature": getattr(block, "signature", "") or "",
                    }
                )
            elif kind == "redacted_thinking":
                thinking_blocks.append(
                    {
                        "type": "redacted_thinking",
                        "data": getattr(block, "data", "") or "",
                    }
                )
        stop_reason = getattr(response, "stop_reason", None)
        _raise_on_refusal(stop_reason, response)
        return AssistantTurn(
            text="".join(text_parts) or None,
            tool_calls=tool_calls,
            finish_reason=_STOP_REASON_MAP.get(stop_reason, stop_reason),
            raw=response,
            reasoning=_reasoning_text(thinking_blocks),
            extras=_thinking_extras(thinking_blocks),
            usage=_usage_from(getattr(response, "usage", None)),
        )

    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()
        if _needs_refusal_fallback(model):
            events = client.beta.messages.create(
                **kwargs,
                betas=[_FALLBACK_BETA],
                fallbacks=[{"model": _FALLBACK_MODEL}],
            )
        else:
            events = client.messages.create(**kwargs)

        text_parts: list[str] = []
        tool_accum: dict[int, dict[str, str]] = {}
        # Thinking blocks accumulate per stream index and must be replayed verbatim later,
        # so both the text and the signature_delta tail are collected (in block order).
        thinking_accum: dict[int, dict[str, Any]] = {}
        stop_reason = None
        usage: Optional[TokenUsage] = None

        last_message_delta: Any = None
        for event in events:
            kind = getattr(event, "type", None)
            if kind == "message_start":
                # Prompt-side counts (input + cache split) ride the opening event.
                usage = (
                    _usage_from(getattr(getattr(event, "message", None), "usage", None))
                    or usage
                )
            elif kind == "content_block_start":
                block = getattr(event, "content_block", None)
                block_kind = getattr(block, "type", None)
                if block_kind == "tool_use":
                    tool_accum[getattr(event, "index", 0)] = {
                        "id": getattr(block, "id", "") or "",
                        "name": getattr(block, "name", "") or "",
                        "json": "",
                    }
                elif block_kind == "thinking":
                    thinking_accum[getattr(event, "index", 0)] = {
                        "type": "thinking",
                        "thinking": getattr(block, "thinking", "") or "",
                        "signature": getattr(block, "signature", "") or "",
                    }
                elif block_kind == "redacted_thinking":
                    # Arrives whole — opaque data, no deltas.
                    thinking_accum[getattr(event, "index", 0)] = {
                        "type": "redacted_thinking",
                        "data": getattr(block, "data", "") or "",
                    }
            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 ""
                elif delta_kind == "thinking_delta":
                    acc = thinking_accum.get(getattr(event, "index", 0))
                    thought = getattr(delta, "thinking", "") or ""
                    if acc is not None and thought:
                        acc["thinking"] += thought
                        yield StreamChunk(reasoning_delta=thought)
                elif delta_kind == "signature_delta":
                    acc = thinking_accum.get(getattr(event, "index", 0))
                    if acc is not None:
                        acc["signature"] = (acc.get("signature") or "") + (
                            getattr(delta, "signature", "") or ""
                        )
            elif kind == "message_delta":
                last_message_delta = getattr(event, "delta", None)
                reason = getattr(last_message_delta, "stop_reason", None)
                if reason:
                    stop_reason = reason
                # Final (cumulative) output-token count rides message_delta.usage.
                out = int(
                    getattr(getattr(event, "usage", None), "output_tokens", 0) or 0
                )
                if out:
                    usage = usage or TokenUsage()
                    usage.output = out

        _raise_on_refusal(stop_reason, last_message_delta)
        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"])
                )
            )
        thinking_blocks = [thinking_accum[i] for i in sorted(thinking_accum)]

        yield StreamChunk(
            turn=AssistantTurn(
                text="".join(text_parts) or None,
                tool_calls=tool_calls,
                finish_reason=_STOP_REASON_MAP.get(stop_reason, stop_reason),
                reasoning=_reasoning_text(thinking_blocks),
                extras=_thinking_extras(thinking_blocks),
                usage=usage,
            )
        )
