"""Shared model provider and defaults configuration models."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Literal, Self, cast

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator

from mindroom.config.validation import duplicate_items, validate_history_limit_choice
from mindroom.constants import (
    DEFAULT_COMPACTION_TIMEOUT_SECONDS,
    DEFAULT_TOOL_OUTPUT_AUTO_SAVE_THRESHOLD_BYTES,
)
from mindroom.credential_policy import credential_service_policy
from mindroom.credentials import validate_service_name
from mindroom.model_defaults import OPENAI_EMBEDDING_SMALL
from mindroom.tool_system.worker_routing import WorkerScope  # noqa: TC001


@dataclass(frozen=True)
class EffectiveToolConfig:
    """Effective authored tool config after defaults and per-agent overrides merge."""

    name: str
    tool_config_overrides: dict[str, object]
    defer: bool = False
    initial: bool = False
    authored_order: int = 0
    authored_name: str | None = None


AgentLearningMode = Literal["always", "agentic"]
_DEFAULT_DEFAULT_TOOLS = ("scheduler",)
_TOOL_CONFIG_CONTROL_KEYS = frozenset({"defer", "initial"})


class StreamingConfig(BaseModel):
    """Timing parameters for streaming response edits."""

    update_interval: float = Field(
        default=5.0,
        gt=0,
        description="Steady-state seconds between message edits during LLM streaming",
    )
    min_update_interval: float = Field(default=0.5, gt=0, description="Fast edit interval at stream start")
    interval_ramp_seconds: float = Field(
        default=15.0,
        ge=0,
        description="Seconds to ramp from min to steady-state interval (0 disables ramp)",
    )
    max_idle: float = Field(
        default=2.0,
        gt=0,
        description=(
            "Flush buffered streaming text on the next streaming event when no new "
            "deltas have arrived for at least this many seconds (event-driven, not "
            "a background timer)."
        ),
    )


class CoalescingConfig(BaseModel):
    """Live dispatch coalescing configuration."""

    model_config = ConfigDict(extra="forbid")

    debounce_ms: int = Field(
        default=1000,
        ge=0,
        description=(
            "Sliding window in milliseconds that a media-tailed live batch waits for more "
            "attachments or a trailing caption; batches ending in text dispatch immediately"
        ),
    )


class DebugConfig(BaseModel):
    """Debug and diagnostic settings."""

    log_llm_requests: bool = False
    llm_request_log_dir: str | None = None


def _normalize_tool_entry_overrides(
    overrides: object,
    *,
    error_message: str,
) -> dict[str, object]:
    """Normalize one inline tool override mapping."""
    if overrides is None:
        return {}
    if not isinstance(overrides, dict):
        raise ValueError(error_message)  # noqa: TRY004 - keep Pydantic validation errors structured
    return cast("dict[str, object]", dict(overrides))


def _coerce_named_tool_entry(data: dict[object, object]) -> dict[str, object]:
    """Normalize the explicit ``{name: ..., overrides: ...}`` form."""
    normalized = cast("dict[str, object]", dict(data))
    extra_keys = set(normalized) - {"name", "overrides", *_TOOL_CONFIG_CONTROL_KEYS}
    if extra_keys:
        msg = "Tool entries with a name key may only include name, overrides, defer, and initial"
        raise ValueError(msg)
    overrides = _normalize_tool_entry_overrides(
        normalized.get("overrides"),
        error_message="Tool entry overrides must be a mapping",
    )
    misplaced_flags = sorted(set(overrides) & _TOOL_CONFIG_CONTROL_KEYS)
    if misplaced_flags:
        msg = "Tool control flags must be declared at the tool-entry level, not inside overrides: " + ", ".join(
            misplaced_flags,
        )
        raise ValueError(msg)
    normalized["overrides"] = overrides
    return normalized


def _coerce_single_key_tool_entry(data: dict[object, object]) -> dict[str, object]:
    """Normalize the compact single-key YAML form."""
    if len(data) != 1:
        msg = (
            "Tool entries must be either a string name or a single-key mapping like "
            "{shell: {extra_env_passthrough: 'DAWARICH_*'}}"
        )
        raise ValueError(msg)

    name, overrides = next(iter(data.items()))
    if not isinstance(name, str):
        msg = "Tool entry names must be strings"
        raise ValueError(msg)  # noqa: TRY004 - keep Pydantic validation errors structured

    normalized_overrides = _normalize_tool_entry_overrides(
        overrides,
        error_message=f"Tool '{name}' overrides must be a mapping",
    )
    control_flags = {
        key: normalized_overrides.pop(key) for key in tuple(_TOOL_CONFIG_CONTROL_KEYS) if key in normalized_overrides
    }
    return {
        "name": name,
        "overrides": normalized_overrides,
        **control_flags,
    }


class ToolConfigEntry(BaseModel):
    """One authored tool entry with optional inline overrides."""

    model_config = ConfigDict(extra="forbid", frozen=True)

    name: str
    overrides: dict[str, object] = Field(default_factory=dict)
    defer: bool = False
    initial: bool = False

    @model_validator(mode="before")
    @classmethod
    def coerce_entry(cls, data: object) -> object:
        """Normalize string and single-key YAML forms into the model shape."""
        if isinstance(data, cls):
            return data
        if isinstance(data, str):
            return {"name": data}
        if isinstance(data, dict):
            entry_dict = cast("dict[object, object]", data)
            return (
                _coerce_named_tool_entry(entry_dict)
                if "name" in entry_dict or "overrides" in entry_dict
                else _coerce_single_key_tool_entry(entry_dict)
            )
        msg = "Tool entries must be strings or single-key mappings"
        raise ValueError(msg)

    @field_validator("name")
    @classmethod
    def validate_name(cls, value: str) -> str:
        """Strip surrounding whitespace and reject empty tool names."""
        stripped = value.strip()
        if not stripped:
            msg = "Tool name must not be empty"
            raise ValueError(msg)
        return stripped

    @model_validator(mode="after")
    def validate_lazy_flags(self) -> Self:
        """Reject redundant initial entries that are not deferred."""
        if self.initial and not self.defer:
            msg = "Tool entry initial=true requires defer=true"
            raise ValueError(msg)
        return self

    @model_serializer(mode="plain")
    def serialize(self) -> object:
        """Preserve the compact YAML form when no overrides are set."""
        if not self.overrides and not self.defer and not self.initial:
            return self.name
        data = dict(self.overrides)
        if self.defer:
            data["defer"] = self.defer
        if self.initial:
            data["initial"] = self.initial
        return {self.name: data}


def validate_unique_tool_entries(
    tools: list[ToolConfigEntry],
    *,
    scope_name: str,
) -> list[ToolConfigEntry]:
    """Ensure each normalized tool name appears at most once within one scope."""
    duplicates = duplicate_items([entry.name for entry in tools])
    if duplicates:
        msg = f"Duplicate {scope_name} tools are not allowed: {', '.join(duplicates)}"
        raise ValueError(msg)
    return tools


def _validate_compaction_threshold_choice(
    *,
    threshold_tokens: int | None,
    threshold_percent: float | None,
) -> None:
    if threshold_tokens is not None and threshold_percent is not None:
        msg = "threshold_tokens and threshold_percent are mutually exclusive"
        raise ValueError(msg)


class CompactionOverrideConfig(BaseModel):
    """Optional per-scope overrides for destructive compaction."""

    enabled: bool | None = Field(
        default=None,
        description="Whether to allow automatic pre-reply destructive compaction for this history scope",
    )
    threshold_tokens: int | None = Field(
        default=None,
        ge=1,
        description="Soft replay trigger budget in tokens",
    )
    threshold_percent: float | None = Field(
        default=None,
        gt=0,
        lt=1,
        description="Soft replay trigger budget as a fraction of the effective replay window",
    )
    replay_window_tokens: int | None = Field(
        default=None,
        ge=1,
        description=(
            "Optional operational cap for persisted replay and required-compaction planning; compaction summary "
            "input is instead budgeted from the selected compaction model's context window"
        ),
    )
    reserve_tokens: int | None = Field(
        default=None,
        ge=0,
        description="Reserved headroom for output and tool definitions",
    )
    model: str | None = Field(
        default=None,
        description="Optional model config name to use for summary generation",
    )
    fallback_model: str | None = Field(
        default=None,
        description=(
            "Optional model config name retried once when the summary model refuses for safeguards; summary input "
            "is rebuilt under the fallback model's context budget when needed"
        ),
    )
    timeout_seconds: float | None = Field(
        default=None,
        gt=0,
        description="Maximum seconds allowed for each compaction summary request",
    )

    @model_validator(mode="after")
    def validate_threshold_choice(self) -> Self:
        """Ensure only one compaction threshold knob is authored at a time."""
        _validate_compaction_threshold_choice(
            threshold_tokens=self.threshold_tokens,
            threshold_percent=self.threshold_percent,
        )
        return self


class CompactionConfig(BaseModel):
    """Concrete destructive compaction configuration."""

    enabled: bool = Field(
        default=True,
        description="Whether to allow automatic pre-reply destructive compaction for this history scope",
    )
    threshold_tokens: int | None = Field(
        default=None,
        ge=1,
        description=(
            "Soft replay trigger budget in tokens "
            "(defaults to 80% of the effective replay window when both thresholds are None)"
        ),
    )
    threshold_percent: float | None = Field(
        default=None,
        gt=0,
        lt=1,
        description="Soft replay trigger budget as a fraction of the effective replay window",
    )
    replay_window_tokens: int | None = Field(
        default=None,
        ge=1,
        description=(
            "Optional operational cap for persisted replay and required-compaction planning; compaction summary "
            "input is instead budgeted from the selected compaction model's context window"
        ),
    )
    reserve_tokens: int = Field(
        default=16384,
        ge=0,
        description="Reserved headroom for output and tool definitions",
    )
    model: str | None = Field(
        default=None,
        description="Optional model config name to use for summary generation",
    )
    fallback_model: str | None = Field(
        default=None,
        description=(
            "Optional model config name retried once when the summary model refuses for safeguards; summary input "
            "is rebuilt under the fallback model's context budget when needed"
        ),
    )
    timeout_seconds: float = Field(
        default=DEFAULT_COMPACTION_TIMEOUT_SECONDS,
        gt=0,
        description="Maximum seconds allowed for each compaction summary request",
    )

    @model_validator(mode="after")
    def validate_threshold_choice(self) -> Self:
        """Ensure only one compaction threshold knob is authored at a time."""
        _validate_compaction_threshold_choice(
            threshold_tokens=self.threshold_tokens,
            threshold_percent=self.threshold_percent,
        )
        return self


class DefaultsConfig(BaseModel):
    """Default configuration values for agents."""

    model_config = ConfigDict(extra="forbid", validate_assignment=True)

    tools: list[ToolConfigEntry] = Field(
        default_factory=lambda: [ToolConfigEntry(name=name) for name in _DEFAULT_DEFAULT_TOOLS],
        description="Tool entries automatically added to every agent, with optional inline overrides",
    )
    markdown: bool = Field(default=True, description="Default markdown setting")
    enable_streaming: bool = Field(
        default=True,
        description="Enable streaming responses via progressive message edits",
    )
    coalescing: CoalescingConfig = Field(
        default_factory=CoalescingConfig,
        description="Live message coalescing settings for rapid same-sender turns",
    )
    show_stop_button: bool = Field(default=True, description="Whether to automatically show stop button on messages")
    auto_resume_after_restart: bool = Field(
        default=True,
        description="Whether restart cleanup should post a real system message to resume interrupted threaded conversations",
    )
    learning: bool = Field(default=True, description="Default Agno Learning setting")
    learning_mode: AgentLearningMode = Field(default="always", description="Default Agno Learning mode")
    compaction: CompactionConfig | None = Field(
        default_factory=CompactionConfig,
        description="Default destructive compaction policy (set to null or enabled=false to disable automatic pre-reply compaction)",
    )
    num_history_runs: int | None = Field(
        default=None,
        ge=1,
        description="Default number of prior Agno runs to include as history context (None = all)",
    )
    num_history_messages: int | None = Field(
        default=None,
        ge=1,
        description="Default max messages from history (mutually exclusive with num_history_runs)",
    )
    compress_tool_results: bool = Field(
        default=False,
        description=(
            "Compress tool results in history to save context. Disabled by default because on Anthropic/Vertex "
            "Claude this can mutate replayed tool messages and invalidate prompt-cache prefixes."
        ),
    )
    max_tool_calls_from_history: int | None = Field(
        default=None,
        ge=0,
        description="Max tool call messages replayed from history (None = no limit)",
    )
    show_tool_calls: bool = Field(
        default=True,
        description="Whether to show tool call details inline in responses",
    )
    worker_tools: list[str] | None = Field(
        default=None,
        description="Tool names to route through scoped workers by default (None = use the built-in default routing policy)",
    )
    worker_scope: WorkerScope | None = Field(
        default=None,
        description="Default worker runtime reuse mode for routed tools: shared, user, or user_agent. user reuses one runtime per requester across agents and is not an agent-level filesystem isolation boundary",
    )
    worker_grantable_credentials: list[str] | None = Field(
        default=None,
        description=(
            "Credential service names to make available inside isolated workers "
            "(None = deny by default). Use built-in names such as openai, anthropic, "
            "google, github_private, and ollama, or custom shared "
            "credential service names saved through the dashboard or API. This setting "
            "only affects tools that actually run inside isolated workers. It never "
            "injects provider env vars such as OPENAI_API_KEY. For worker-routed tools, "
            "it only controls which shared credentials MindRoom may load inside isolated "
            "workers, and it does "
            "not affect local shared-only integrations such as homeassistant because "
            "those stay in the main runtime. "
            "Google OAuth client config, Google OAuth tokens, and google_vertex_adc are "
            "intentionally unsupported in isolated workers and must stay in the main runtime."
        ),
    )
    allow_self_config: bool = Field(
        default=False,
        description="Default setting for allowing agents to modify their own configuration",
    )
    max_preload_chars: int = Field(
        default=50000,
        ge=1,
        description="Hard cap for extra role preload context loaded from context_files",
    )
    tool_output_auto_save_threshold_bytes: int = Field(
        default=DEFAULT_TOOL_OUTPUT_AUTO_SAVE_THRESHOLD_BYTES,
        ge=1,
        description=(
            "Supported tool outputs larger than this many bytes are automatically saved to the agent workspace "
            "and replaced with a compact receipt in the model-visible tool result"
        ),
    )
    streaming: StreamingConfig = Field(
        default_factory=StreamingConfig,
        description="Streaming response timing parameters",
    )
    thread_summary_model: str | None = Field(
        default=None,
        description="Model config name for generating thread summaries (e.g., 'haiku'). Uses 'default' if not set.",
    )
    thread_summary_temperature: float | None = Field(
        default=0.2,
        description=(
            "Temperature override for automatic thread summaries. "
            "Set to null to omit temperature and use provider defaults. "
            "MindRoom always uses provider temperature defaults for Vertex Claude, Claude Opus 5, Sonnet 5, "
            "Fable 5, and direct Google Gemini 3.6 Flash and Gemini 3.5 Flash-Lite thread summaries."
        ),
    )
    thread_summary_first_threshold: int = Field(
        default=1,
        ge=1,
        description="Message count required before the first automatic thread summary is generated.",
    )
    thread_summary_subsequent_interval: int = Field(
        default=10,
        ge=1,
        description="Additional message count required between automatic thread summaries after the first one.",
    )

    @model_validator(mode="before")
    @classmethod
    def reject_legacy_defaults_fields(cls, data: object) -> object:
        """Reject removed legacy fields to prevent silent misconfiguration."""
        if isinstance(data, dict):
            if "sandbox_tools" in data:
                msg = "defaults.sandbox_tools was removed. Use defaults.worker_tools instead."
                raise ValueError(msg)
            if "allowed_toolkits" in data:
                msg = "defaults.allowed_toolkits was removed. Use defaults.tools instead."
                raise ValueError(msg)
            if "initial_toolkits" in data:
                msg = "defaults.initial_toolkits was removed. Use defaults.tools instead."
                raise ValueError(msg)
        return data

    @model_validator(mode="after")
    def _check_history_config(self) -> Self:
        validate_history_limit_choice(
            num_history_runs=self.num_history_runs,
            num_history_messages=self.num_history_messages,
        )
        return self

    @property
    def tool_names(self) -> list[str]:
        """Return default tool names without inline override details."""
        return [entry.name for entry in self.tools]

    @field_validator("tools")
    @classmethod
    def validate_unique_tools(cls, tools: list[ToolConfigEntry]) -> list[ToolConfigEntry]:
        """Ensure each default tool appears at most once."""
        validated = validate_unique_tool_entries(tools, scope_name="default")
        lazy_entries = [entry.name for entry in validated if entry.model_fields_set & {"defer", "initial"}]
        if lazy_entries:
            msg = "defaults.tools does not support defer or initial flags: " + ", ".join(lazy_entries)
            raise ValueError(msg)
        return validated

    @field_validator("worker_grantable_credentials")
    @classmethod
    def validate_worker_grantable_credentials(
        cls,
        services: list[str] | None,
    ) -> list[str] | None:
        """Normalize configured worker-grantable credential service names."""
        if services is None:
            return None
        normalized_services = [validate_service_name(service) for service in services]
        unsupported_services = sorted(
            {
                service
                for service in normalized_services
                if not credential_service_policy(service, None).worker_grantable_supported
            },
        )
        if unsupported_services:
            msg = (
                "worker_grantable_credentials does not support "
                f"{', '.join(unsupported_services)}. These credentials must stay in the main runtime, "
                "not isolated workers."
            )
            raise ValueError(msg)
        return normalized_services


class EmbedderConfig(BaseModel):
    """Configuration for memory embedder."""

    model: str = Field(default=OPENAI_EMBEDDING_SMALL, description="Model name for embeddings")
    credentials_service: str | None = Field(
        default=None,
        description=(
            "Optional credential service used only by this embedder. When omitted, legacy resolution checks the "
            "dedicated 'embedder' service and then the provider credential"
        ),
    )
    api_key: str | None = Field(
        default=None,
        description=(
            "Explicit embedder API key. Highest priority, above credentials_service and the legacy "
            "dedicated embedder-to-openai fallback"
        ),
    )
    host: str | None = Field(default=None, description="Host URL for self-hosted models (Ollama, llama.cpp, etc.)")
    dimensions: int | None = Field(
        default=None,
        ge=1,
        description="Optional embedding dimension override for OpenAI-compatible providers",
    )

    @field_validator("credentials_service")
    @classmethod
    def _validate_credentials_service(cls, value: str | None) -> str | None:
        """Normalize an optional named credential reference."""
        return None if value is None else validate_service_name(value)


class ModelConfig(BaseModel):
    """Configuration for an AI model."""

    provider: str = Field(
        description="Model provider (openai, anthropic, vertexai_claude, ollama, etc)",
    )
    id: str = Field(description="Model ID specific to the provider")
    host: str | None = Field(default=None, description="Optional host URL (e.g., for Ollama)")
    api_key: str | None = Field(default=None, description="Optional API key (usually from env vars)")
    extra_kwargs: dict[str, Any] | None = Field(
        default=None,
        description="Additional provider-specific parameters passed directly to the model",
    )
    context_window: int | None = Field(
        default=None,
        ge=1,
        description=(
            "Actual provider context window size in tokens. MindRoom uses it for compaction summary input and as "
            "the default replay-planning window unless compaction.replay_window_tokens sets a smaller replay cap. "
            "An explicit compaction.model or compaction.fallback_model also needs its own context_window for "
            "summary generation. "
            "On vertexai_claude models it additionally "
            "enables request-time fitting that trims replayed history when a request would exceed the window"
        ),
    )


class RouterConfig(BaseModel):
    """Configuration for the router system."""

    model: str = Field(default="default", description="Model to use for routing decisions")
    accept_invites: bool = Field(default=True, description="Whether the router accepts and persists room invites")
    startup_thread_prewarm: bool = Field(
        default=True,
        description="Whether the router may prewarm recent thread snapshots for rooms already joined when first sync completes",
    )
