"""Minimal agent runner orchestrating provider + tools.

The runner is intentionally small — no streaming, no tool-calling auto loop
beyond a single hop. It exists to:

1. Glue the registry pieces together so the rest of the platform has a
   single ``run_agent`` entry point.
2. Give tests a stable surface to exercise the AI flow without depending
   on any external service.

Real agentic loops, tool-call planning, and streaming will land on top in a
follow-up phase.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any

from simorgh.apps.ai.context import AIContext, render_context_block
from simorgh.apps.ai.prompts import get_prompt
from simorgh.apps.ai.providers import ChatResponse, Message, get_provider
from simorgh.apps.ai.tools import get_tool


class AgentError(RuntimeError):
    """Raised on runner-level configuration / invocation errors."""


@dataclass(frozen=True)
class AgentSpec:
    key: str
    label_key: str
    provider: str = "echo"
    system_prompt_key: str = ""
    allowed_tools: tuple[str, ...] = ()
    description: str = ""

    def __post_init__(self) -> None:
        if not self.key:
            raise AgentError("agent key required")
        # Touch the provider to fail fast on bad config (still optional —
        # registration may pre-date provider wiring in tests).
        if not self.label_key:
            raise AgentError(f"agent {self.key!r}: label_key required")


@dataclass
class AgentRun:
    agent: str
    response: ChatResponse
    tool_invocations: list[dict[str, Any]] = field(default_factory=list)
    context: AIContext | None = None


_AGENTS: dict[str, AgentSpec] = {}


def register_agent(spec: AgentSpec) -> AgentSpec:
    existing = _AGENTS.get(spec.key)
    if existing is not None and existing != spec:
        raise AgentError(f"agent {spec.key!r} already registered with different config")
    _AGENTS[spec.key] = spec
    return spec


def get_agent(key: str) -> AgentSpec:
    try:
        return _AGENTS[key]
    except KeyError as exc:
        raise AgentError(f"unknown agent {key!r}") from exc


def list_agents() -> list[AgentSpec]:
    return sorted(_AGENTS.values(), key=lambda s: s.key)


def reset_for_tests() -> None:
    _AGENTS.clear()


def _system_message(spec: AgentSpec, context: AIContext | None) -> Message | None:
    parts: list[str] = []
    if spec.system_prompt_key:
        parts.append(get_prompt(spec.system_prompt_key).render())
    if context is not None:
        parts.append("## Context\n" + render_context_block(context))
    if not parts:
        return None
    return Message(role="system", content="\n\n".join(parts))


def run_agent(
    agent_key: str,
    *,
    user_input: str,
    context: AIContext | None = None,
    history: Sequence[Message] = (),
    tool_calls: Sequence[dict[str, Any]] = (),
    provider_opts: dict[str, Any] | None = None,
) -> AgentRun:
    """Execute one chat round through the given agent.

    ``tool_calls`` is an explicit list of ``{"name": ..., "payload": {...}}``
    items to run *before* the model call; their results are appended as
    tool messages. This keeps the surface deterministic for tests while
    still letting callers exercise the tool registry end-to-end.
    """
    spec = get_agent(agent_key)
    messages: list[Message] = []
    system = _system_message(spec, context)
    if system is not None:
        messages.append(system)
    messages.extend(history)

    invocations: list[dict[str, Any]] = []
    for call in tool_calls:
        name = call.get("name", "")
        if spec.allowed_tools and name not in spec.allowed_tools:
            raise AgentError(
                f"agent {spec.key!r}: tool {name!r} not in allowed list",
            )
        payload = call.get("payload") or {}
        tool = get_tool(name)
        result = tool.handler(**payload)
        invocations.append({"name": name, "payload": payload, "result": result})
        messages.append(
            Message(
                role="tool",
                name=name,
                content=str(result),
            ),
        )

    messages.append(Message(role="user", content=user_input))
    provider = get_provider(spec.provider)
    response = provider.chat(messages, **(provider_opts or {}))
    return AgentRun(
        agent=spec.key,
        response=response,
        tool_invocations=invocations,
        context=context,
    )
