"""Tests for netanel-core configuration.

Covers: schema validation, YAML loading, defaults, edge cases.
Architecture ref: Config is the single source of truth (CLAUDE.md).
"""

from pathlib import Path

import pytest
import yaml
from pydantic import ValidationError

from netanel_core.config import (
    EvalConfig,
    EvolutionConfig,
    ModelConfig,
    NathanConfig,
    SafetyBounds,
)

# ────────────────────────────────────────────
# Task 1a: Config Schema
# ────────────────────────────────────────────


class TestSafetyBounds:
    """Safety bounds must enforce JPL rules — no unbounded values."""

    def test_defaults_are_sensible(self):
        """Verify all safety bounds have sensible default values."""
        bounds = SafetyBounds()
        assert bounds.max_iterations == 50
        assert bounds.max_tokens_per_task == 100_000
        assert bounds.max_learnings_per_call == 10
        assert bounds.max_retries == 3

    def test_rejects_zero_iterations(self):
        """Zero iterations is invalid — must be at least 1."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_iterations=0)

    def test_rejects_negative_tokens(self):
        """Negative token budget is invalid."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_tokens_per_task=-1)

    def test_rejects_iterations_above_max(self):
        """Iterations above 500 is invalid — JPL upper bound."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_iterations=501)

    def test_rejects_retries_above_max(self):
        """Retries above 10 is invalid — upper bound."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_retries=11)

    def test_rejects_tokens_below_minimum(self):
        """Token budget below 1000 is invalid — minimum viable budget."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_tokens_per_task=999)

    def test_max_learnings_per_call_lower_bound(self):
        """max_learnings_per_call of 0 is invalid — must be at least 1 (BUG-052)."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_learnings_per_call=0)

    def test_max_learnings_per_call_upper_bound(self):
        """max_learnings_per_call of 51 is invalid — must be at most 50 (BUG-052)."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_learnings_per_call=51)

    def test_max_learnings_per_call_accepts_valid(self):
        """max_learnings_per_call within [1, 50] is accepted (BUG-052)."""
        assert SafetyBounds(max_learnings_per_call=1).max_learnings_per_call == 1
        assert SafetyBounds(max_learnings_per_call=25).max_learnings_per_call == 25
        assert SafetyBounds(max_learnings_per_call=50).max_learnings_per_call == 50

    def test_accepts_valid_custom_values(self):
        """Custom values within bounds are accepted."""
        bounds = SafetyBounds(
            max_iterations=10,
            max_tokens_per_task=5000,
            max_retries=5,
        )
        assert bounds.max_iterations == 10
        assert bounds.max_retries == 5

    def test_model_timeout_seconds_default(self):
        """Default model_timeout_seconds is 60 seconds."""
        bounds = SafetyBounds()
        assert bounds.model_timeout_seconds == 60

    def test_model_timeout_seconds_rejects_below_minimum(self):
        """Values below 5 are rejected — ge=5 constraint."""
        with pytest.raises(ValidationError):
            SafetyBounds(model_timeout_seconds=4)

    def test_model_timeout_seconds_rejects_above_maximum(self):
        """Values above 300 are rejected — le=300 constraint."""
        with pytest.raises(ValidationError):
            SafetyBounds(model_timeout_seconds=301)

    def test_model_timeout_seconds_accepts_valid_value(self):
        """Valid custom timeout is accepted and stored correctly."""
        bounds = SafetyBounds(model_timeout_seconds=120)
        assert bounds.model_timeout_seconds == 120

    def test_backend_type_default(self):
        """Default backend_type is local_shell — delivers all three pillars."""
        bounds = SafetyBounds()
        assert bounds.backend_type == "local_shell"

    def test_backend_type_accepts_all_valid_values(self):
        """All three Literal values are accepted."""
        assert SafetyBounds(backend_type="ephemeral").backend_type == "ephemeral"
        assert SafetyBounds(backend_type="filesystem").backend_type == "filesystem"
        assert SafetyBounds(backend_type="local_shell").backend_type == "local_shell"

    def test_backend_type_rejects_invalid_value(self):
        """Invalid backend_type strings are rejected by Pydantic."""
        with pytest.raises(ValidationError):
            SafetyBounds(backend_type="invalid")  # type: ignore[arg-type]

    def test_backend_root_dir_default_is_none(self):
        """Default backend_root_dir is None — means current working directory."""
        bounds = SafetyBounds()
        assert bounds.backend_root_dir is None

    def test_backend_root_dir_accepts_custom_path(self):
        """Custom backend_root_dir string is stored correctly."""
        bounds = SafetyBounds(backend_root_dir="/tmp/agent_work")
        assert bounds.backend_root_dir == "/tmp/agent_work"


class TestModelConfig:
    """Model config must have defaults for all three roles."""

    def test_defaults_exist(self):
        """All three model roles have non-empty default values."""
        models = ModelConfig()
        assert models.primary_model
        assert models.evaluator_model
        assert models.extractor_model

    def test_evaluator_tier2_model_default(self):
        """Tier 2 evaluator model has a default (gpt-4o for escalation)."""
        models = ModelConfig()
        assert models.evaluator_tier2_model == "gpt-4o"

    def test_custom_models(self):
        """Custom model names are accepted and stored correctly."""
        models = ModelConfig(
            primary_model="gpt-4o-mini",
            evaluator_model="claude-sonnet-4-20250514",
            extractor_model="gpt-4o-mini",
            evaluator_tier2_model="claude-sonnet-4-20250514",
        )
        assert models.primary_model == "gpt-4o-mini"
        assert models.evaluator_tier2_model == "claude-sonnet-4-20250514"


class TestEvolutionConfig:
    """Evolution triggers must be bounded and valid."""

    def test_defaults(self):
        """Default evolution triggers match architecture spec."""
        evo = EvolutionConfig()
        assert evo.call_count_trigger == 20
        assert evo.failure_rate_trigger == 0.6
        assert evo.failure_window == 5

    def test_rejects_zero_call_count(self):
        """Zero call count trigger is invalid — must be at least 1."""
        with pytest.raises(ValidationError):
            EvolutionConfig(call_count_trigger=0)

    def test_rejects_failure_window_below_two(self):
        """Failure window below 2 is invalid — need at least 2 calls to measure rate."""
        with pytest.raises(ValidationError):
            EvolutionConfig(failure_window=1)

    def test_rejects_zero_pattern_trigger(self):
        """Pattern accumulation trigger of 0 is invalid — must be at least 1."""
        with pytest.raises(ValidationError):
            EvolutionConfig(pattern_accumulation_trigger=0)

    def test_rejects_rollback_window_below_two(self):
        """Rollback window below 2 is invalid — need enough calls to judge."""
        with pytest.raises(ValidationError):
            EvolutionConfig(rollback_window=1)

    def test_rejects_failure_rate_out_of_bounds(self):
        """Failure rate must be between 0.0 and 1.0."""
        with pytest.raises(ValidationError):
            EvolutionConfig(failure_rate_trigger=1.5)
        with pytest.raises(ValidationError):
            EvolutionConfig(failure_rate_trigger=-0.1)

    def test_max_evolution_tokens_default(self):
        """Default max_evolution_tokens is 4096 — well within gpt-4o-mini's 16,384 limit."""
        evo = EvolutionConfig()
        assert evo.max_evolution_tokens == 4096

    def test_max_evolution_tokens_rejects_below_lower_bound(self):
        """max_evolution_tokens below 256 is invalid — too small for a rewritten prompt."""
        with pytest.raises(ValidationError):
            EvolutionConfig(max_evolution_tokens=255)

    def test_max_evolution_tokens_rejects_above_upper_bound(self):
        """max_evolution_tokens above 16384 exceeds gpt-4o-mini completion limit."""
        with pytest.raises(ValidationError):
            EvolutionConfig(max_evolution_tokens=16385)

    def test_max_evolution_tokens_accepts_valid_custom(self):
        """Valid value within [256, 16384] is accepted."""
        evo = EvolutionConfig(max_evolution_tokens=8192)
        assert evo.max_evolution_tokens == 8192

    def test_max_evolution_tokens_accepts_lower_bound(self):
        """Lower bound (256) is accepted."""
        evo = EvolutionConfig(max_evolution_tokens=256)
        assert evo.max_evolution_tokens == 256

    def test_max_evolution_tokens_accepts_upper_bound(self):
        """Upper bound (16384) is accepted."""
        evo = EvolutionConfig(max_evolution_tokens=16384)
        assert evo.max_evolution_tokens == 16384


class TestEvalConfig:
    """Progressive quality ratchet must have valid bounds on all fields."""

    def test_defaults(self):
        """All 8 fields have correct default values."""
        ec = EvalConfig()
        assert ec.initial_threshold == 0.85
        assert ec.max_threshold == 0.95
        assert ec.auto_raise_increment == 0.05
        assert ec.auto_raise_buffer == 0.1
        assert ec.rolling_window == 20
        assert ec.uncertainty_range == 0.1
        assert ec.low_confidence_threshold == 0.7
        assert ec.calibration_calls == 50

    def test_rejects_initial_threshold_above_one(self):
        """Threshold above 1.0 is invalid — scores are 0-1."""
        with pytest.raises(ValidationError):
            EvalConfig(initial_threshold=1.1)

    def test_rejects_initial_threshold_below_zero(self):
        """Threshold below 0.0 is invalid."""
        with pytest.raises(ValidationError):
            EvalConfig(initial_threshold=-0.1)

    def test_rejects_max_threshold_above_one(self):
        """Max threshold above 1.0 is invalid."""
        with pytest.raises(ValidationError):
            EvalConfig(max_threshold=1.1)

    def test_rejects_rolling_window_below_min(self):
        """Rolling window below 5 is invalid — not enough data."""
        with pytest.raises(ValidationError):
            EvalConfig(rolling_window=4)

    def test_rejects_rolling_window_above_max(self):
        """Rolling window above 100 is invalid — too much history."""
        with pytest.raises(ValidationError):
            EvalConfig(rolling_window=101)

    def test_rejects_calibration_calls_below_min(self):
        """Calibration calls below 10 is invalid — not enough data."""
        with pytest.raises(ValidationError):
            EvalConfig(calibration_calls=9)

    def test_rejects_calibration_calls_above_max(self):
        """Calibration calls above 500 is invalid — wasteful."""
        with pytest.raises(ValidationError):
            EvalConfig(calibration_calls=501)

    def test_rejects_auto_raise_increment_below_min(self):
        """Increment below 0.01 is invalid — too small to matter."""
        with pytest.raises(ValidationError):
            EvalConfig(auto_raise_increment=0.005)

    def test_rejects_auto_raise_increment_above_max(self):
        """Increment above 0.2 is invalid — too aggressive."""
        with pytest.raises(ValidationError):
            EvalConfig(auto_raise_increment=0.25)

    def test_rejects_uncertainty_range_below_min(self):
        """Uncertainty range below 0.01 is invalid."""
        with pytest.raises(ValidationError):
            EvalConfig(uncertainty_range=0.005)

    def test_rejects_uncertainty_range_above_max(self):
        """Uncertainty range above 0.3 is invalid."""
        with pytest.raises(ValidationError):
            EvalConfig(uncertainty_range=0.35)

    def test_rejects_low_confidence_above_one(self):
        """Low confidence threshold above 1.0 is invalid."""
        with pytest.raises(ValidationError):
            EvalConfig(low_confidence_threshold=1.1)

    def test_rejects_low_confidence_below_zero(self):
        """Low confidence threshold below 0.0 is invalid."""
        with pytest.raises(ValidationError):
            EvalConfig(low_confidence_threshold=-0.1)

    def test_rejects_auto_raise_buffer_below_min(self):
        """Auto raise buffer below 0.01 is invalid — too small."""
        with pytest.raises(ValidationError):
            EvalConfig(auto_raise_buffer=0.005)

    def test_rejects_auto_raise_buffer_above_max(self):
        """Auto raise buffer above 0.5 is invalid — too large."""
        with pytest.raises(ValidationError):
            EvalConfig(auto_raise_buffer=0.6)

    def test_rejects_initial_above_max_threshold(self):
        """Cross-field: initial_threshold cannot exceed max_threshold."""
        with pytest.raises(ValidationError):
            EvalConfig(initial_threshold=0.95, max_threshold=0.7)

    def test_accepts_equal_thresholds(self):
        """Cross-field: initial == max is valid (fixed threshold, no ratchet)."""
        ec = EvalConfig(initial_threshold=0.8, max_threshold=0.8)
        assert ec.initial_threshold == ec.max_threshold

    def test_accepts_valid_custom_values(self):
        """All fields accept valid non-default values."""
        ec = EvalConfig(
            initial_threshold=0.5,
            max_threshold=0.9,
            auto_raise_increment=0.1,
            auto_raise_buffer=0.2,
            rolling_window=50,
            uncertainty_range=0.15,
            low_confidence_threshold=0.5,
            calibration_calls=100,
        )
        assert ec.initial_threshold == 0.5
        assert ec.rolling_window == 50
        assert ec.calibration_calls == 100


# ────────────────────────────────────────────
# Task 1c: Defaults
# ────────────────────────────────────────────


class TestNathanConfigDefaults:
    """Config must work out of the box with zero arguments."""

    def test_default_creation(self):
        """Factory method creates config with all sensible defaults."""
        config = NathanConfig.default()
        assert config.namespace == "default"
        assert config.memories_dir == Path("memories")
        assert config.safety.max_iterations == 50
        assert config.models.primary_model

    def test_namespace_dir_property(self):
        """Namespace directory is memories_dir / namespace."""
        config = NathanConfig(namespace="coder")
        assert config.namespace_dir == Path("memories/coder")

    def test_global_dir_property(self):
        """Global directory is memories_dir / global."""
        config = NathanConfig()
        assert config.global_dir == Path("memories/global")

    def test_patterns_dir_property(self):
        """Patterns directory is namespace_dir / patterns."""
        config = NathanConfig(namespace="designer")
        assert config.patterns_dir == Path("memories/designer/patterns")

    def test_prompts_dir_property(self):
        """Prompts directory is namespace_dir / prompts."""
        config = NathanConfig(namespace="planner")
        assert config.prompts_dir == Path("memories/planner/prompts")

    def test_custom_memories_dir(self):
        """Custom memories_dir propagates to all path properties."""
        config = NathanConfig(memories_dir=Path("/tmp/test_memories"))
        assert config.global_dir == Path("/tmp/test_memories/global")

    def test_empty_tools_by_default(self):
        """No additional tools are registered by default (empty tuple)."""
        config = NathanConfig()
        assert config.tools == ()

    def test_custom_tools(self):
        """Custom tools list is coerced to tuple and stored correctly."""
        config = NathanConfig(tools=["code_analyzer", "test_runner"])
        assert "code_analyzer" in config.tools
        assert len(config.tools) == 2
        assert isinstance(config.tools, tuple)


# ────────────────────────────────────────────
# Task 1b: YAML Loading
# ────────────────────────────────────────────


class TestNathanConfigYAML:
    """Config must load from YAML files correctly."""

    def test_load_full_config(self, tmp_path):
        """Full YAML config is loaded with all fields mapped correctly."""
        config_file = tmp_path / "config.yaml"
        config_file.write_text(
            yaml.dump(
                {
                    "namespace": "coder",
                    "memories_dir": str(tmp_path / "memories"),
                    "models": {
                        "primary_model": "gpt-4o-mini",
                        "evaluator_model": "claude-sonnet-4-20250514",
                        "extractor_model": "gpt-4o-mini",
                    },
                    "safety": {
                        "max_iterations": 25,
                    },
                    "tools": ["test_runner"],
                }
            )
        )
        config = NathanConfig.from_yaml(config_file)
        assert config.namespace == "coder"
        assert config.models.primary_model == "gpt-4o-mini"
        assert config.safety.max_iterations == 25
        assert "test_runner" in config.tools

    def test_load_partial_config_uses_defaults(self, tmp_path):
        """Partial YAML fills in defaults for missing fields."""
        config_file = tmp_path / "config.yaml"
        config_file.write_text(yaml.dump({"namespace": "designer"}))
        config = NathanConfig.from_yaml(config_file)
        assert config.namespace == "designer"
        # Everything else should be default
        assert config.safety.max_iterations == 50
        assert config.models.primary_model == "gpt-4o-mini"

    def test_load_empty_yaml_returns_defaults(self, tmp_path):
        """Empty YAML file returns config with all defaults."""
        config_file = tmp_path / "config.yaml"
        config_file.write_text("")
        config = NathanConfig.from_yaml(config_file)
        assert config.namespace == "default"

    def test_load_missing_file_raises(self):
        """Non-existent config file raises FileNotFoundError."""
        with pytest.raises(FileNotFoundError):
            NathanConfig.from_yaml("/nonexistent/path/config.yaml")

    def test_load_invalid_yaml_raises(self, tmp_path):
        """Malformed YAML raises yaml.YAMLError."""
        config_file = tmp_path / "config.yaml"
        config_file.write_text("invalid: yaml: content: [")
        with pytest.raises(yaml.YAMLError):
            NathanConfig.from_yaml(config_file)

    def test_load_invalid_values_raises(self, tmp_path):
        """Valid YAML with invalid values raises ValidationError."""
        config_file = tmp_path / "config.yaml"
        config_file.write_text(yaml.dump({"safety": {"max_iterations": -5}}))
        with pytest.raises(ValidationError):
            NathanConfig.from_yaml(config_file)

    def test_rejects_extra_fields(self, tmp_path):
        """Extra fields in YAML are rejected — ConfigDict(extra='forbid')."""
        config_file = tmp_path / "config.yaml"
        config_file.write_text(yaml.dump({"namespace": "test", "unknown_field": "should_fail"}))
        with pytest.raises(ValidationError):
            NathanConfig.from_yaml(config_file)

    def test_load_with_evolution_config(self, tmp_path):
        """YAML with evolution section loads EvolutionConfig correctly."""
        config_file = tmp_path / "config.yaml"
        config_file.write_text(
            yaml.dump(
                {
                    "namespace": "evolver",
                    "evolution": {
                        "call_count_trigger": 10,
                        "failure_rate_trigger": 0.8,
                        "failure_window": 3,
                        "pattern_accumulation_trigger": 5,
                        "rollback_window": 3,
                    },
                }
            )
        )
        config = NathanConfig.from_yaml(config_file)
        assert config.evolution.call_count_trigger == 10
        assert config.evolution.failure_rate_trigger == 0.8
        assert config.evolution.failure_window == 3
        assert config.evolution.pattern_accumulation_trigger == 5
        assert config.evolution.rollback_window == 3

    def test_load_with_evaluation_config(self, tmp_path):
        """YAML with evaluation section loads EvalConfig correctly."""
        config_file = tmp_path / "config.yaml"
        config_file.write_text(
            yaml.dump(
                {
                    "namespace": "evaluator",
                    "evaluation": {
                        "initial_threshold": 0.6,
                        "max_threshold": 0.9,
                        "auto_raise_increment": 0.1,
                        "auto_raise_buffer": 0.15,
                        "rolling_window": 10,
                        "uncertainty_range": 0.05,
                        "low_confidence_threshold": 0.8,
                        "calibration_calls": 100,
                    },
                }
            )
        )
        config = NathanConfig.from_yaml(config_file)
        assert config.evaluation.initial_threshold == 0.6
        assert config.evaluation.max_threshold == 0.9
        assert config.evaluation.auto_raise_increment == 0.1
        assert config.evaluation.auto_raise_buffer == 0.15
        assert config.evaluation.rolling_window == 10
        assert config.evaluation.uncertainty_range == 0.05
        assert config.evaluation.low_confidence_threshold == 0.8
        assert config.evaluation.calibration_calls == 100


# ────────────────────────────────────────────
# Directory Creation
# ────────────────────────────────────────────


class TestEnsureDirectories:
    """Config must create memory directories on demand."""

    def test_creates_all_directories(self, tmp_path):
        """All required directories are created: patterns, prompts, global."""
        config = NathanConfig(
            namespace="test_ns",
            memories_dir=tmp_path / "memories",
        )
        config.ensure_directories()

        assert (tmp_path / "memories" / "test_ns" / "patterns").is_dir()
        assert (tmp_path / "memories" / "test_ns" / "prompts").is_dir()
        assert (tmp_path / "memories" / "global" / "patterns").is_dir()

    def test_idempotent(self, tmp_path):
        """Calling ensure_directories twice does not raise."""
        config = NathanConfig(
            namespace="test_ns",
            memories_dir=tmp_path / "memories",
        )
        # Call twice — should not raise
        config.ensure_directories()
        config.ensure_directories()
        assert (tmp_path / "memories" / "test_ns" / "patterns").is_dir()


# ────────────────────────────────────────────
# BUG-032: frozen=True — all config models immutable after init
# ────────────────────────────────────────────


class TestFrozenModels:
    """All 6 config models must be frozen after construction (BUG-032)."""

    def test_frozen_config_construction_succeeds(self):
        """Basic NathanConfig instantiation still works with frozen=True."""
        config = NathanConfig()
        assert config.namespace == "default"

    def test_frozen_nathan_config_mutation_raises(self):
        """Direct mutation of NathanConfig raises ValidationError."""
        config = NathanConfig()
        with pytest.raises(ValidationError):
            config.namespace = "mutated"  # type: ignore[misc]

    def test_frozen_safety_bounds_mutation_raises(self):
        """Direct mutation of SafetyBounds raises ValidationError."""
        bounds = SafetyBounds()
        with pytest.raises(ValidationError):
            bounds.max_iterations = 999  # type: ignore[misc]

    def test_frozen_model_config_mutation_raises(self):
        """Direct mutation of ModelConfig raises ValidationError."""
        mc = ModelConfig()
        with pytest.raises(ValidationError):
            mc.primary_model = "gpt-5"  # type: ignore[misc]

    def test_frozen_eval_config_mutation_raises(self):
        """Direct mutation of EvalConfig raises ValidationError."""
        ec = EvalConfig()
        with pytest.raises(ValidationError):
            ec.initial_threshold = 0.99  # type: ignore[misc]

    def test_model_copy_creates_new_instance(self):
        """model_validate(model_dump()) creates updated instance — validates changes."""
        config = NathanConfig(namespace="original")
        data = config.model_dump()
        data["namespace"] = "updated"
        updated = NathanConfig.model_validate(data)
        assert updated.namespace == "updated"
        assert config.namespace == "original"  # Original unchanged

    def test_nested_model_frozen_via_copy(self):
        """Nested frozen models can be replaced via model_validate(model_dump())."""
        config = NathanConfig()
        new_safety = SafetyBounds(max_iterations=10)
        data = config.model_dump()
        data["safety"] = new_safety.model_dump()
        updated = NathanConfig.model_validate(data)
        assert updated.safety.max_iterations == 10
        assert config.safety.max_iterations == 50  # Original unchanged

    def test_model_validate_enforces_validation_on_update(self):
        """model_validate(model_dump()) catches invalid updates — unlike model_copy."""
        config = NathanConfig(namespace="valid")
        data = config.model_dump()
        data["namespace"] = "invalid/namespace"  # Contains illegal character
        with pytest.raises(ValidationError):
            NathanConfig.model_validate(data)

    def test_tools_immutable_tuple_cannot_append(self):
        """tools is a tuple — .append() raises AttributeError (BUG-032 fix)."""
        config = NathanConfig(namespace="test", tools=("tool_a", "tool_b"))
        with pytest.raises(AttributeError):
            config.tools.append("tool_c")  # type: ignore[attr-defined]

    def test_eval_config_validator_runs_before_freeze(self):
        """@model_validator(mode='after') executes correctly with frozen=True."""
        with pytest.raises(ValidationError):
            EvalConfig(initial_threshold=0.9, max_threshold=0.8)  # Invalid ordering


# ────────────────────────────────────────────
# BUG-034: max_tokens_per_task upper bound (le=1_000_000)
# ────────────────────────────────────────────


class TestMaxTokensUpperBound:
    """max_tokens_per_task must have an upper bound (BUG-034)."""

    def test_max_tokens_upper_bound_accepted(self):
        """1,000,000 tokens is accepted — at the upper bound."""
        bounds = SafetyBounds(max_tokens_per_task=1_000_000)
        assert bounds.max_tokens_per_task == 1_000_000

    def test_max_tokens_above_upper_bound_rejected(self):
        """1,000,001 tokens exceeds the upper bound and is rejected."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_tokens_per_task=1_000_001)

    def test_max_tokens_lower_bound_still_enforced(self):
        """999 tokens is still rejected — lower bound still in effect."""
        with pytest.raises(ValidationError):
            SafetyBounds(max_tokens_per_task=999)

    def test_max_tokens_default_within_bounds(self):
        """Default 100,000 is within [1000, 1_000_000]."""
        bounds = SafetyBounds()
        assert 1000 <= bounds.max_tokens_per_task <= 1_000_000


# ────────────────────────────────────────────
# BUG-010: namespace validation — safe directory names only
# ────────────────────────────────────────────


class TestNamespaceValidation:
    """Namespace must be a safe directory name (BUG-010)."""

    # Valid names
    def test_default_namespace_accepted(self):
        """'default' namespace is valid."""
        assert NathanConfig().namespace == "default"

    def test_single_char_accepted(self):
        """Single alphanumeric or underscore characters are valid."""
        assert NathanConfig(namespace="a").namespace == "a"
        assert NathanConfig(namespace="_").namespace == "_"
        assert NathanConfig(namespace="1").namespace == "1"

    def test_numeric_only_accepted(self):
        """Purely numeric namespaces are valid directory names."""
        config = NathanConfig(namespace="12345")
        assert config.namespace == "12345"

    def test_hyphen_in_middle_accepted(self):
        """Hyphens within the name are valid."""
        config = NathanConfig(namespace="my-app")
        assert config.namespace == "my-app"

    def test_underscore_accepted(self):
        """Underscores are valid."""
        config = NathanConfig(namespace="project_a")
        assert config.namespace == "project_a"

    def test_dot_hierarchical_accepted(self):
        """Dot-separated hierarchical namespaces are valid."""
        assert NathanConfig(namespace="app.module").namespace == "app.module"
        assert NathanConfig(namespace="team.project.env").namespace == "team.project.env"

    # Invalid: leading/trailing/consecutive dots
    def test_leading_dot_rejected(self):
        """Namespace starting with a dot is rejected."""
        with pytest.raises(ValidationError):
            NathanConfig(namespace=".test")

    def test_trailing_dot_rejected(self):
        """Namespace ending with a dot is rejected."""
        with pytest.raises(ValidationError):
            NathanConfig(namespace="test.")

    def test_consecutive_dots_rejected(self):
        """Consecutive dots (potential path traversal) are rejected."""
        with pytest.raises(ValidationError):
            NathanConfig(namespace="test..prod")

    # Invalid: dangerous characters
    def test_slash_rejected(self):
        """Slashes create nested directories — rejected."""
        with pytest.raises(ValidationError):
            NathanConfig(namespace="with/slash")

    def test_backslash_rejected(self):
        """Backslashes are rejected."""
        with pytest.raises(ValidationError):
            NathanConfig(namespace="with\\backslash")

    def test_spaces_rejected(self):
        """Spaces in namespace are rejected."""
        with pytest.raises(ValidationError):
            NathanConfig(namespace="with spaces")

    def test_empty_namespace_rejected(self):
        """Empty namespace string is rejected."""
        with pytest.raises(ValidationError):
            NathanConfig(namespace="")

    def test_leading_hyphen_rejected(self):
        """Namespace starting with a hyphen is rejected — CLI flag ambiguity."""
        with pytest.raises(ValidationError):
            NathanConfig(namespace="-test")

    @pytest.mark.parametrize("namespace", NathanConfig.RESERVED_NAMESPACES)
    def test_reserved_namespace_rejected(self, namespace):
        """Every reserved namespace is rejected — covers all entries in RESERVED_NAMESPACES."""
        with pytest.raises(ValidationError, match="reserved"):
            NathanConfig(namespace=namespace)

    def test_non_reserved_namespace_accepted(self):
        """Valid, non-reserved namespaces are still accepted after BUG-062 fix."""
        config = NathanConfig(namespace="my-project")
        assert config.namespace == "my-project"


# ────────────────────────────────────────────
# BUG-087: rolling_window constraint documented
# ────────────────────────────────────────────


class TestBug087RollingWindowDescription:
    """BUG-087: EvalConfig.rolling_window description must mention the ge=5 minimum.

    Users who pass rolling_window < 5 receive a terse ValidationError with no
    explanation of why 5 is the minimum. The field description must proactively
    explain the constraint so documentation tools (Pydantic schema, IDE hints)
    surface the reason before the error occurs.
    """

    def test_rolling_window_description_mentions_minimum(self):
        """Field description contains 'Minimum 5' so users understand the constraint."""
        fields = EvalConfig.model_fields
        description = fields["rolling_window"].description
        assert description is not None, "rolling_window must have a description"
        assert "Minimum 5" in description, (
            f"rolling_window description must contain 'Minimum 5', got: {description!r}"
        )

    def test_rolling_window_rejects_below_minimum_with_ge_constraint(self):
        """Values below 5 are still rejected — description does not relax the constraint."""
        with pytest.raises(ValidationError, match="greater than or equal to 5"):
            EvalConfig(rolling_window=4)

    def test_rolling_window_accepts_minimum_value(self):
        """The minimum valid value (5) is accepted."""
        ec = EvalConfig(rolling_window=5)
        assert ec.rolling_window == 5


# ────────────────────────────────────────────
# BUG-085: initial_threshold default calibration
# ────────────────────────────────────────────


class TestBug085InitialThresholdDefault:
    """BUG-085: EvalConfig.initial_threshold default must be 0.85 (not 0.7).

    With gpt-4o-mini as evaluator, simple-to-moderate tasks score 0.80-0.90 on
    the first attempt. A default of 0.7 renders the quality retry loop permanently
    dormant for most workloads. The fix raises the default to 0.85 to ensure the
    loop fires when output quality is genuinely substandard.
    """

    def test_initial_threshold_default_is_085(self):
        """Default initial_threshold must be 0.85 — calibrated for gpt-4o-mini scores."""
        ec = EvalConfig()
        assert ec.initial_threshold == 0.85, (
            f"initial_threshold default must be 0.85 (not 0.7) to ensure the "
            f"quality retry loop fires for substandard outputs. Got: {ec.initial_threshold}"
        )

    def test_initial_threshold_description_mentions_calibration(self):
        """Field description must mention gpt-4o-mini calibration rationale."""
        description = EvalConfig.model_fields["initial_threshold"].description
        assert description is not None, "initial_threshold must have a description"
        assert "gpt-4o-mini" in description, (
            f"initial_threshold description must mention 'gpt-4o-mini' calibration, "
            f"got: {description!r}"
        )

    def test_initial_threshold_description_mentions_retry_loop(self):
        """Field description must explain why the threshold matters for the retry loop."""
        description = EvalConfig.model_fields["initial_threshold"].description
        assert description is not None
        assert "retry" in description.lower() or "loop" in description.lower(), (
            f"initial_threshold description must reference the retry loop, "
            f"got: {description!r}"
        )


# ────────────────────────────────────────────
# BUG-089: pattern_accumulation_trigger documentation
# ────────────────────────────────────────────


class TestBug089PatternAccumulationTriggerDoc:
    """BUG-089: EvolutionConfig.pattern_accumulation_trigger description must clarify
    that 2-3 patterns are extracted per call, so evolution fires more frequently
    than the numeric value implies.

    The counting logic is NOT changed — only the description is updated and the
    default is confirmed at 10.
    """

    def test_pattern_accumulation_trigger_default_is_10(self):
        """Default pattern_accumulation_trigger must be 10."""
        ev = EvolutionConfig()
        assert ev.pattern_accumulation_trigger == 10, (
            f"pattern_accumulation_trigger default must be 10. Got: {ev.pattern_accumulation_trigger}"
        )

    def test_pattern_accumulation_trigger_description_mentions_extraction_rate(self):
        """Field description must explain the 2-3 patterns per call extraction rate."""
        description = EvolutionConfig.model_fields["pattern_accumulation_trigger"].description
        assert description is not None, "pattern_accumulation_trigger must have a description"
        assert "2" in description and "3" in description, (
            f"description must mention '2-3 patterns per call', got: {description!r}"
        )

    def test_pattern_accumulation_trigger_description_recommends_10x(self):
        """Field description must recommend setting to 10x the desired call count."""
        description = EvolutionConfig.model_fields["pattern_accumulation_trigger"].description
        assert description is not None
        assert "10" in description, (
            f"description must mention '10x' recommendation, got: {description!r}"
        )

    def test_pattern_accumulation_trigger_description_warns_frequency(self):
        """Field description must warn that evolution fires more frequently than number suggests."""
        description = EvolutionConfig.model_fields["pattern_accumulation_trigger"].description
        assert description is not None
        assert "more frequently" in description.lower(), (
            f"description must warn 'more frequently', got: {description!r}"
        )
