"""ModelProvider interface + the shared reply shape and helpers.

Message/tool shapes are the Anthropic Messages-API shapes, used as the
canonical wire format (the OpenAI-compatible adapter translates in V3.5):
messages = [{"role": ..., "content": str | [blocks]}], tools =
[{"name", "description", "input_schema"}].
"""
import os
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field

# The engine's built-in default (digest.py:86 parity).
DEFAULT_MODEL = "claude-opus-4-8"


class ModelProviderError(RuntimeError):
    """Provider-level failure. Providers RAISE; whether to swallow is the
    caller's decision (the engine's outbound digests propagate, its inbound
    parsers swallow to an empty result — that split stays at the call sites,
    docs/V3-PLAN.md §2.1)."""


@dataclass
class ModelReply:
    """One assistant turn, provider-agnostic."""
    text: str                       # concatenated text blocks, stripped
    tool_calls: list = field(default_factory=list)   # [{"id", "name", "input"}]
    usage: dict = field(default_factory=dict)        # {"input_tokens", "output_tokens"}
    stop_reason: str = ""
    raw_content: object = None      # the provider's content blocks, echoable in a follow-up turn

    def unfenced(self) -> str:
        """The text with a wrapping code fence removed (call-site parity)."""
        return strip_wrapping_fences(self.text)


def strip_wrapping_fences(text: str) -> str:
    """Byte-exact port of the five call sites' fence strip (digest.py:1246-1248):
    a leading ``` (with optional language tag) and a trailing ``` are removed
    independently. Interior fences — real code blocks in a report — survive."""
    text = re.sub(r"^```[a-zA-Z]*\s*", "", text)
    return re.sub(r"\s*```$", "", text).strip()


def resolve_model(config=None, purpose=None, env=None) -> str:
    """The model id, with back-compat precedence (docs/V3-PLAN.md §4 P2):
    ``$DIGEST_MODEL`` (existing deployments' operator override) →
    ``config.model.<purpose>`` → ``config.model.default`` → the built-in.
    Env wins so a rendered config gaining ``model.default`` never silently
    overrides a vault that already pins ``DIGEST_MODEL``."""
    env = os.environ if env is None else env
    if env.get("DIGEST_MODEL"):
        return env["DIGEST_MODEL"]
    model_cfg = (config or {}).get("model")
    if not isinstance(model_cfg, dict):  # tolerate a malformed block (validate() flags it)
        model_cfg = {}
    if purpose:
        val = model_cfg.get(purpose)
        if isinstance(val, str) and val.strip():   # a per-purpose value may now be a
            return val                              # per-tier object — ignore non-strings here
    default = model_cfg.get("default")
    return default if isinstance(default, str) and default.strip() else DEFAULT_MODEL


def resolve_model_tiered(config=None, tool=None, tier=None, *, override=None, env=None) -> str:
    """Model id for a (tool, tier) pair — the explicit **tier dimension** the
    server-side agentic loops route on (docs/V3-PLAN.md §4 P2). A *gather* stage
    (a spider subagent reading notes) runs a cheap model; a *synthesize* stage
    (the merge/verify judgment) runs a frontier model — that split is the margin
    lever the box-side loop earns its place on.

    Precedence, first non-empty wins — flat universal tiers with per-tool
    override on top (both shapes, one chain):

      ``override``                    a caller-supplied whole-tool pin
                                      (e.g. ``vault_mcp.research.model``)
      → ``config.model.<tool>.<tier>``  per-tool tier override (nested)
      → ``config.model.<tier>``         the universal tier (the cross-tool lever)
      → ``config.model.<tool>``         a plain per-tool string (back-compat)
      → ``config.model.default``
      → :data:`DEFAULT_MODEL`

    Unset tiers collapse to ``model.default``, so the whole feature is a **no-op
    until an operator sets a tier** — an unconfigured vault behaves exactly as
    before. Deliberately does not consult ``$DIGEST_MODEL`` (that back-compat knob
    is the digest's alone; :func:`resolve_model` still honors it)."""
    if isinstance(override, str) and override.strip():
        return override
    model_cfg = (config or {}).get("model")
    if not isinstance(model_cfg, dict):
        model_cfg = {}
    tool_node = model_cfg.get(tool) if tool else None
    candidates = []
    if isinstance(tool_node, dict) and tier:
        candidates.append(tool_node.get(tier))     # model.<tool>.<tier>
    if tier:
        candidates.append(model_cfg.get(tier))     # model.<tier> (universal)
    if isinstance(tool_node, str):
        candidates.append(tool_node)               # model.<tool> (plain string)
    candidates.append(model_cfg.get("default"))
    for c in candidates:
        if isinstance(c, str) and c.strip():
            return c
    return DEFAULT_MODEL


class ModelProvider(ABC):
    """``chat`` is one request/response turn; loops live in :mod:`.loop`."""

    name = "abstract"

    @abstractmethod
    def chat(self, messages, *, system=None, model=None, max_tokens=4096,
             tools=None, temperature=None, stream=False, tool_choice=None,
             cache=False) -> ModelReply:
        """Send one turn. Raises on failure (see ModelProviderError).
        ``tool_choice``/``cache`` are honored by providers that advertise the
        matching cap; callers gate on :meth:`caps` before passing them."""

    def caps(self) -> dict:
        """What this provider supports — callers gate features on this
        (``prompt_cache`` gates the tool loop's cache breakpoints)."""
        return {"tools": False, "streaming": False}
