"""Configuration for nathan-core.

Single source of truth for all components. Every other module reads from this.
Supports loading from YAML file or programmatic creation with sensible defaults.

Architecture ref: ARCHITECTURE.md Section 9 (Dependencies), Section 10 (Safety Bounds)
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import ClassVar, Literal

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

# Compiled once at module load — used by NathanConfig._validate_namespace (BUG-010).
# Pattern: alphanumeric/underscore start, dots allowed as namespace separators.
_NAMESPACE_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_-]*(\.[a-zA-Z0-9_][a-zA-Z0-9_-]*)*$")

# Reserved namespace names (BUG-062) — these collide with system-managed directories.
# "global" → memories/global/ (cross-namespace shared patterns)
# "shared" → reserved for future cross-namespace shared state
# "system" → reserved for internal system use
RESERVED_NAMESPACES: frozenset[str] = frozenset({"global", "shared", "system"})


class SafetyBounds(BaseModel):
    """JPL-inspired safety limits. Non-negotiable."""

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

    max_iterations: int = Field(
        default=50,
        ge=1,
        le=500,
        description="Max ReAct loop iterations before forced stop",
    )
    max_tokens_per_task: int = Field(
        default=100_000,
        ge=1000,
        le=1_000_000,
        description=(
            "Budget cap: max tokens consumed per task (1,000–1,000,000). "
            "Default 100k covers most workloads; raise for very large context tasks."
        ),
    )
    max_learnings_per_call: int = Field(
        default=10,
        ge=1,
        le=50,
        description=(
            "Max learnings stored per extraction call (JPL bounded growth). "
            "Prevents hallucinated extraction floods from filling memory disk."
        ),
    )
    max_retries: int = Field(
        default=3,
        ge=1,
        le=10,
        description="Max quality loop retries (evaluate → improve → retry)",
    )
    model_timeout_seconds: int = Field(
        default=60,
        ge=5,
        le=300,
        description="Timeout in seconds for LLM model.invoke() calls.",
    )
    backend_type: Literal["ephemeral", "filesystem", "local_shell"] = Field(
        default="local_shell",
        description=(
            "deepagents backend for agent execution. "
            "'ephemeral': StateBackend (in-memory only, no real filesystem access). "
            "'filesystem': FilesystemBackend (real files, no shell). "
            "'local_shell': LocalShellBackend (real files + bash + search — all three pillars)."
        ),
    )
    backend_root_dir: str | None = Field(
        default=None,
        description=(
            "Root directory for filesystem-based backends (filesystem, local_shell). "
            "None = current working directory (deepagents default)."
        ),
    )


class ModelConfig(BaseModel):
    """LLM model configuration for different roles in the system."""

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

    # The primary model for the main LLM call
    primary_model: str = Field(
        default="gpt-4o-mini",
        description="Model for the main task call ($0.15/1M input, $0.60/1M output)",
    )
    # The model used for evaluation (Tier 1 — cheap, fast quality judge)
    evaluator_model: str = Field(
        default="gpt-4o-mini",
        description="Model for Tier 1 quality evaluation (Tier 2 uses primary model)",
    )
    # The model used for learning extraction
    extractor_model: str = Field(
        default="gpt-4o-mini",
        description="Model for extracting learnings from responses",
    )
    # The model used for Tier 2 evaluation (escalation + calibration)
    evaluator_tier2_model: str = Field(
        default="gpt-4o",
        description="Model for Tier 2 evaluation when Tier 1 is uncertain ($2.50/1M input)",
    )


class EvolutionConfig(BaseModel):
    """Prompt evolution trigger settings."""

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

    call_count_trigger: int = Field(
        default=20,
        ge=1,
        description="Evolve prompt every N calls",
    )
    failure_rate_trigger: float = Field(
        default=0.6,
        ge=0.0,
        le=1.0,
        description="Evolve if failure rate exceeds this in last N calls (3 of 5 = 0.6)",
    )
    failure_window: int = Field(
        default=5,
        ge=2,
        description="Number of recent calls to check for failure rate",
    )
    pattern_accumulation_trigger: int = Field(
        default=10,
        ge=1,
        description=(
            "Number of stored patterns before evolution fires. "
            "Note: 2–3 patterns are extracted per call on average, so evolution fires "
            "more frequently than the number suggests. "
            "Recommend setting to 10x your desired call count. Default 10."
        ),
    )
    rollback_window: int = Field(
        default=5,
        ge=2,
        description="Rollback if next N calls after evolution perform worse",
    )
    rollback_threshold: float = Field(
        default=0.6,
        ge=0.0,
        le=1.0,
        description="Minimum acceptable avg score after evolution. Rollback only if post_avg < pre_avg AND post_avg < this threshold",
    )
    max_evolution_tokens: int = Field(
        default=4096,
        ge=256,
        le=16384,
        description=(
            "Maximum completion tokens for evolution LLM calls. "
            "Separate from max_tokens_per_task (task budget). "
            "gpt-4o-mini limit: 16,384."
        ),
    )


class ToolSelectorConfig(BaseModel):
    """Tool scoring and selection settings."""

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

    scoring_window: int = Field(
        default=20,
        ge=2,
        le=100,
        description="Sliding window size: only last N calls per tool affect score",
    )
    default_score: float = Field(
        default=0.5,
        ge=0.0,
        le=1.0,
        description="Cold start score for tools with no history",
    )
    max_history_entries: int = Field(
        default=1000,
        ge=100,
        le=10000,
        description="Max lines in tool_history.jsonl before compaction",
    )
    failing_threshold: float = Field(
        default=0.3,
        ge=0.0,
        le=1.0,
        description="Default threshold for get_failing_tools() — tools below this are flagged",
    )


class EvalConfig(BaseModel):
    """Progressive quality ratchet settings.

    Controls how the Evaluator raises quality standards over time.
    Start low (achievable), auto-raise as the system improves, cap at max.
    """

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

    initial_threshold: float = Field(
        default=0.85,
        ge=0.0,
        le=1.0,
        description=(
            "Starting quality threshold. Calibrated for gpt-4o-mini as evaluator: "
            "simple-to-moderate tasks typically score 0.80–0.90 on the first attempt. "
            "Setting below 0.80 renders the quality retry loop permanently dormant for most workloads. "
            "Default 0.85 ensures the loop fires when output quality is genuinely substandard."
        ),
    )
    max_threshold: float = Field(
        default=0.95,
        ge=0.0,
        le=1.0,
        description="Quality threshold cap (never reaches 1.0 — unrealistic)",
    )
    auto_raise_increment: float = Field(
        default=0.05,
        ge=0.01,
        le=0.2,
        description="How much to raise threshold each time",
    )
    auto_raise_buffer: float = Field(
        default=0.1,
        ge=0.01,
        le=0.5,
        description="Rolling avg must exceed threshold + buffer to trigger raise",
    )
    rolling_window: int = Field(
        default=20,
        ge=5,
        le=100,
        description=(
            "Rolling window size for threshold auto-raise calculations. "
            "Minimum 5 — must be large enough to detect stable trends. Default: 20."
        ),
    )
    uncertainty_range: float = Field(
        default=0.1,
        ge=0.01,
        le=0.3,
        description="Score within this range of threshold triggers Tier 2 escalation",
    )
    low_confidence_threshold: float = Field(
        default=0.7,
        ge=0.0,
        le=1.0,
        description="Tier 1 confidence below this triggers Tier 2 escalation",
    )
    calibration_calls: int = Field(
        default=50,
        ge=10,
        le=500,
        description="Number of calls to run both tiers for calibration",
    )

    @model_validator(mode="after")
    def _check_threshold_ordering(self) -> EvalConfig:
        """Ensure initial_threshold does not exceed max_threshold."""
        if self.initial_threshold > self.max_threshold:
            msg = (
                f"initial_threshold ({self.initial_threshold}) must be "
                f"<= max_threshold ({self.max_threshold})"
            )
            raise ValueError(msg)
        return self


class NathanConfig(BaseModel):
    """Root configuration for nathan-core.

    This is the single source of truth. Every component reads from here.
    Load from YAML file or create programmatically.
    """

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

    # Reserved namespaces (class-level constant for external access)
    RESERVED_NAMESPACES: ClassVar[frozenset[str]] = RESERVED_NAMESPACES

    # Identity
    namespace: str = Field(
        default="default",
        description=(
            "Memory namespace (e.g., 'coder', 'app.module'). "
            "Allowed: letters, digits, hyphens, underscores, dot-separated segments. "
            r"Pattern: ^[a-zA-Z0-9_][a-zA-Z0-9_-]*(\.[a-zA-Z0-9_][a-zA-Z0-9_-]*)*$"
        ),
    )

    @field_validator("namespace")
    @classmethod
    def _validate_namespace(cls, v: str) -> str:
        """Validate namespace is safe for use as a directory name (BUG-010, BUG-062)."""
        if not _NAMESPACE_RE.match(v):
            raise ValueError(
                f"Invalid namespace {v!r}. Must start with a letter, digit, or "
                "underscore; may contain hyphens; dot separates hierarchical segments "
                r"(pattern: ^[a-zA-Z0-9_][a-zA-Z0-9_-]*(\.[a-zA-Z0-9_][a-zA-Z0-9_-]*)*$)."
            )
        if v.lower() in {name.lower() for name in RESERVED_NAMESPACES}:
            raise ValueError(
                f"namespace {v!r} is reserved. Reserved names: {sorted(RESERVED_NAMESPACES)}"
            )
        return v

    # Paths
    memories_dir: Path = Field(
        default=Path("memories"),
        description="Root directory for all memory files",
    )

    # Models
    models: ModelConfig = Field(
        default_factory=ModelConfig,
        description="LLM model configuration",
    )

    # Safety
    safety: SafetyBounds = Field(
        default_factory=SafetyBounds,
        description="JPL-inspired safety bounds",
    )

    # Evolution
    evolution: EvolutionConfig = Field(
        default_factory=EvolutionConfig,
        description="Prompt evolution trigger settings",
    )

    # Evaluation
    evaluation: EvalConfig = Field(
        default_factory=EvalConfig,
        description="Progressive quality ratchet settings",
    )

    # Tool Selection
    tool_selector: ToolSelectorConfig = Field(
        default_factory=ToolSelectorConfig,
        description="Tool scoring and selection settings",
    )

    # Tools: tuple of additional tool names for this namespace (beyond 3 pillars).
    # Immutable tuple preserves frozen=True semantics — list[str] would allow
    # mutation via .append() even with frozen=True (BUG-032).
    tools: tuple[str, ...] = Field(
        default=(),
        description="Additional tool names from registry for this namespace",
    )

    @field_validator("tools", mode="before")
    @classmethod
    def _coerce_tools_to_tuple(cls, v: object) -> tuple[str, ...]:
        """Coerce list[str] input to tuple[str, ...] for immutability."""
        if isinstance(v, (list, tuple)):
            return tuple(v)
        raise ValueError(f"tools must be a list or tuple of strings, got {type(v).__name__}")

    @property
    def namespace_dir(self) -> Path:
        """Path to this namespace's memory directory."""
        return self.memories_dir / self.namespace

    @property
    def global_dir(self) -> Path:
        """Path to the global shared memory directory."""
        return self.memories_dir / "global"

    @property
    def patterns_dir(self) -> Path:
        """Path to this namespace's patterns directory."""
        return self.namespace_dir / "patterns"

    @property
    def prompts_dir(self) -> Path:
        """Path to this namespace's prompts directory."""
        return self.namespace_dir / "prompts"

    @classmethod
    def from_yaml(cls, path: str | Path) -> NathanConfig:
        """Load config from a YAML file.

        Args:
            path: Path to YAML config file.

        Returns:
            NathanConfig instance.

        Raises:
            FileNotFoundError: If config file doesn't exist.
            yaml.YAMLError: If YAML is malformed.
            pydantic.ValidationError: If values fail validation.
        """
        config_path = Path(path)
        if not config_path.exists():
            raise FileNotFoundError(f"Config file not found: {config_path}")

        with open(config_path, encoding="utf-8") as f:
            raw = yaml.safe_load(f)

        if raw is None:
            return cls()

        return cls.model_validate(raw)

    @classmethod
    def default(cls) -> NathanConfig:
        """Create config with all defaults. Works out of the box."""
        return cls()

    def ensure_directories(self) -> None:
        """Create all required memory directories if they don't exist.

        Creates:
            - memories/{namespace}/patterns/
            - memories/{namespace}/prompts/
            - memories/global/patterns/
        """
        self.patterns_dir.mkdir(parents=True, exist_ok=True)
        self.prompts_dir.mkdir(parents=True, exist_ok=True)
        (self.global_dir / "patterns").mkdir(parents=True, exist_ok=True)
