"""Kernel run configuration and event emitter (docs/kernel_spec.md §2).

``RunConfig`` replaces ``execute_raw_agent``'s ~35 parameters; ``RunEvents``
replaces its ~12 ``on_*`` callbacks. Surfaces are *profiles* (data), not a
spread of booleans. Peripherals contribute system-prompt text through
``context_providers``; the kernel just concatenates in registered order and
never knows what a "personality" or a "User Model" is.

Boundary: stdlib only.
"""
from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum

_log = logging.getLogger(__name__)


class Profile(str, Enum):
    """Run surface. Only CHAT is exercised in Phase 3a; the rest are reserved
    for Phase 4 (subagent / kanban_worker / quick_panel / dashboard)."""

    CHAT = "chat"
    SUBAGENT = "subagent"
    KANBAN_WORKER = "kanban_worker"
    QUICK_PANEL = "quick_panel"
    DASHBOARD = "dashboard"


# A context provider contributes one system-prompt block per run, or None to
# contribute nothing this run. Personality, User Model render_for_prompt,
# curiosity/insights, knowledge presence all become providers in Phase 3b.
ContextProvider = Callable[["RunConfig"], "str | None"]


@dataclass
class RunTraceHooks:
    """Optional peripheral trace callbacks. Core never imports conversation_trace.

    Each hook is fail-open: a broken tracer must not take down a run.
    """

    on_turn_start: Callable[[int, int], None] | None = None
    on_llm_request: Callable[[int, list, dict], None] | None = None
    on_llm_response: Callable[[int, str, list], None] | None = None
    on_tool_call: Callable[[int, str, str, str, float], None] | None = None
    on_turn_end: Callable[[int, str | None, str], None] | None = None


def _always_false() -> bool:
    return False


@dataclass
class RunConfig:
    """Everything the kernel loop needs to drive one run."""

    thread_id: str
    model_id: str
    base_url: str
    profile: Profile = Profile.CHAT
    api_key: str = ""
    temperature: float | None = None
    max_tokens: int | None = None
    # Loop budget (spec §5 allowed exception): a hard turn cap plus an
    # identical-consecutive-call cap so a confused model halts instead of
    # spinning. ``max_repeats`` counts repeats *after* the first occurrence.
    max_turns: int = 12
    max_repeats: int = 3
    # Consecutive turns whose tool calls are all the same tool name (any args).
    max_same_tool_turns: int = 5
    context_providers: list[ContextProvider] = field(default_factory=list)
    stop_check: Callable[[], bool] = _always_false
    # Forwarded verbatim to the model client (provider-specific knobs).
    extra_body: dict | None = None
    # Optional peripheral-injected content salvage: (content) -> (calls, cleaned).
    # Lets a surface plug in a richer tool-call parser (e.g. the legacy
    # multi-format / Gemma channel-style parser) without core importing it.
    content_salvage: Callable | None = None
    # Optional peripheral-injected assistant-text sanitizer: (text) -> text.
    # Strips reasoning-channel leakage (e.g. <think> / <|channel>thought /
    # bare "thought" preambles) and tool markup from the final reply before it
    # is persisted and shown. Kept out of core so the kernel never imports the
    # legacy sanitizer; the wiring layer injects the real implementation.
    text_sanitizer: Callable[[str], str] | None = None
    # Optional sanitizer for assistant content on tool-call turns (before the
    # message is appended to in-run history). Strips bare channel closers like
    # ``<channel|>`` that Gemma sometimes emits alongside native tool_calls.
    assistant_tool_content_sanitizer: Callable[[str], str] | None = None
    # Optional guard (spec §5): when the model promises action after a tool
    # failure but emits no tool call, return a user-role retry prompt or None.
    noop_promise_retry: Callable[[str, list], str | None] | None = None
    max_noop_promise_retries: int = 3
    # Optional cap on serialized non-system history size (chars). When exceeded,
    # oldest non-system messages are dropped with a tombstone before each turn.
    max_history_chars: int | None = None
    # Optional peripheral-injected conversation trace hooks (run_start/end live
    # in the wiring layer; per-turn hooks fire from the loop).
    trace_hooks: RunTraceHooks | None = None

    def system_prompt(self) -> str:
        """Concatenate context-provider blocks in registered order.

        A provider returning ``None``/empty contributes nothing. A provider
        that raises is skipped rather than failing the whole run — peripherals
        must not be able to take down the kernel.
        """
        blocks: list[str] = []
        for provider in self.context_providers:
            try:
                text = provider(self)
            except Exception:
                _log.warning(
                    "context provider %s raised; skipping",
                    getattr(provider, "__name__", repr(provider)),
                    exc_info=True,
                )
                text = None
            if text and text.strip():
                blocks.append(text.strip())
        return "\n\n".join(blocks)


def _noop_chunk(_text: str) -> None:
    ...


def _noop_status(_text: str) -> None:
    ...


def _noop_tool_event(_event: dict) -> None:
    ...


def _noop_finished(_text: str) -> None:
    ...


def _noop_error(_text: str) -> None:
    ...


def _noop_log(_level: str, _message: str) -> None:
    ...


@dataclass
class RunEvents:
    """Output sink for one run. Defaults are no-ops so a caller can subscribe
    to only what it needs. These map 1:1 onto what ``agent_worker`` passes to
    the legacy ``run_raw_agent`` today, so the Phase 3b adapter is mechanical.
    """

    on_chunk: Callable[[str], None] = _noop_chunk
    on_status: Callable[[str], None] = _noop_status
    on_tool_event: Callable[[dict], None] = _noop_tool_event
    on_finished: Callable[[str], None] = _noop_finished
    on_error: Callable[[str], None] = _noop_error
    on_log: Callable[[str, str], None] = _noop_log
