"""Tests for PersonaSpec, JobSpec, and related schemas.

Phase 5, step 2: Schema validation and edge cases.
"""

from datetime import datetime
from pathlib import Path
from typing import Any, cast

import pytest
from pydantic import ValidationError

from anima.core.schemas import (
    AgentAlreadyExistsError,
    AgentNotFoundError,
    AgentNotInteractiveError,
    AgentNotTerminalError,
    AgentResult,
    AgentState,
    AgentStateData,
    AgentTerminatedError,
    AnimaError,
    AnimaGlobalConfig,
    GlobalDefaultsConfig,
    AnimaTimeoutError,
    ApprovalRequiredError,
    BudgetExhaustedError,
    CanSpawn,
    CapabilityToken,
    CompletionPolicy,
    ContextBundle,
    ContextMode,
    ERROR_CODES,
    InternalError,
    InvalidInputError,
    JobSpec,
    LifecycleType,
    LogEntry,
    MaxDepthExceededError,
    MaxTurnsReachedError,
    MCPConnectionError,
    MCPConnectionRefusedError,
    MCPSocketNotFoundError,
    Meta,
    NotFoundError,
    PersonaSpec,
    RawTailEntry,
    SideEffectPolicy,
    SummaryRef,
    TokenUsage,
    ToolBlockedError,
    ToolCallCount,
    ToolPresetConfig,
    UnsupportedModelError,
    build_error_envelope,
    _validate_can_spawn,
)


# =============================================================================
# Enum Tests
# =============================================================================


class TestEnums:
    """Tests for all enum types."""

    def test_agent_state_values(self) -> None:
        """AgentState has correct string values."""
        assert AgentState.pending.value == "pending"
        assert AgentState.running.value == "running"
        assert AgentState.suspended.value == "suspended"
        assert AgentState.awaiting_approval.value == "awaiting_approval"
        assert AgentState.completed.value == "completed"
        assert AgentState.failed.value == "failed"
        assert AgentState.killed.value == "killed"

    def test_agent_state_from_string(self) -> None:
        """AgentState can be created from string."""
        assert AgentState("pending") == AgentState.pending
        assert AgentState("running") == AgentState.running
        assert AgentState("failed") == AgentState.failed

    def test_lifecycle_type_values(self) -> None:
        """LifecycleType has correct values."""
        assert LifecycleType.ephemeral.value == "ephemeral"
        assert LifecycleType.daemon.value == "daemon"

    def test_completion_policy_values(self) -> None:
        """CompletionPolicy has correct values."""
        assert CompletionPolicy.explicit.value == "explicit"
        assert CompletionPolicy.auto.value == "auto"

    def test_side_effect_policy_values(self) -> None:
        """SideEffectPolicy has correct values."""
        assert SideEffectPolicy.allow.value == "allow"
        assert SideEffectPolicy.approval_required.value == "approval_required"
        assert SideEffectPolicy.read_only.value == "read_only"

    def test_context_mode_values(self) -> None:
        """ContextMode has correct values."""
        assert ContextMode.isolated.value == "isolated"
        assert ContextMode.shared.value == "shared"

    def test_enum_count(self) -> None:
        """Verify we have the expected number of enum values."""
        # This test ensures we don't accidentally remove an enum value
        assert len(AgentState) == 7
        assert len(LifecycleType) == 2
        assert len(CompletionPolicy) == 2
        assert len(SideEffectPolicy) == 3
        assert len(ContextMode) == 2


# =============================================================================
# PersonaSpec Tests
# =============================================================================


class TestPersonaSpec:
    """Tests for PersonaSpec validation."""

    def test_minimal_valid_persona(self) -> None:
        """PersonaSpec with only required fields."""
        p = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="You are a test.",
            model="gpt-4",
        )
        assert p.id == "test"
        assert p.description == "A test persona."
        assert p.prompt == "You are a test."
        assert p.model == "gpt-4"
        assert p.spec_version == "0.1.0"
        assert p.can_spawn is False
        assert p.tools == {}

    def test_prompt_required(self) -> None:
        """PersonaSpec rejects omitted prompt."""
        with pytest.raises(ValidationError) as exc_info:
            PersonaSpec(id="test", description="A test persona.", model="gpt-4")  # type: ignore[call-arg]
        errors = exc_info.value.errors()
        assert any(err.get("loc") == ("prompt",) for err in errors)

    def test_prompt_non_empty(self) -> None:
        """PersonaSpec rejects empty prompt."""
        with pytest.raises(ValidationError) as exc_info:
            PersonaSpec(
                id="test",
                description="A test persona.",
                prompt="",
                model="gpt-4",
            )
        errors = exc_info.value.errors()
        assert any(err.get("loc") == ("prompt",) for err in errors)

    def test_tools_none_rejected(self) -> None:
        """PersonaSpec rejects explicit tools=None."""
        with pytest.raises(ValidationError) as exc_info:
            PersonaSpec(
                id="test",
                description="A test persona.",
                prompt="You are a test.",
                model="gpt-4",
                tools=None,  # type: ignore[arg-type]
            )
        errors = exc_info.value.errors()
        assert any(err.get("loc") == ("tools",) for err in errors)

    def test_tools_invalid_posture_rejected(self) -> None:
        """PersonaSpec rejects legacy/unsupported posture values like 'deny'."""
        # These legacy values should be rejected - they are not valid ToolPosture values
        for invalid_value in ("deny", "block", "forbid"):
            with pytest.raises(ValidationError) as exc_info:
                PersonaSpec(
                    id="test-invalid-posture",
                    description="A test persona.",
                    prompt="You are a test.",
                    model="gpt-4",
                    tools={"write_file": invalid_value},  # type: ignore[arg-type]
                )
            errors = exc_info.value.errors()
            # Check that error is on tools.write_file field
            assert any(
                err.get("loc", ()) == ("tools", "write_file") for err in errors
            ), f"Expected error for invalid posture '{invalid_value}'"

    def test_tools_omitted_defaults_to_empty_map(self) -> None:
        """PersonaSpec canonicalizes omitted tools as empty map."""
        p = PersonaSpec(
            id="test",
            description="A test persona.",
            prompt="You are a test.",
            model="gpt-4",
        )
        assert p.tools == {}
        assert p.model_dump()["tools"] == {}

    def test_tools_empty_no_warning_and_validates(self) -> None:
        """Empty tools is valid and does not emit a warning."""
        import warnings

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            p = PersonaSpec(
                id="test-empty-tools",
                description="A test persona.",
                prompt="You are a test.",
                model="gpt-4",
                tools={},
            )
        assert p.tools == {}
        assert not any("WARNING #2" in str(w.message) for w in caught)

    def test_mutating_tools_without_policy_emits_warning_and_validates(self) -> None:
        """WARNING #3: read_write/destructive tools without policy emits UserWarning."""
        with pytest.warns(UserWarning, match="WARNING #3"):
            p = PersonaSpec(
                id="test-mutating-no-policy",
                description="A test persona.",
                prompt="You are a test.",
                model="gpt-4",
                tools={"write_file": "read_write"},
            )
        assert p.tools["write_file"].value == "read_write"
        assert p.side_effect_policy is None

    def test_destructive_tools_with_read_only_policy_fails_validation(self) -> None:
        """ERROR #6: destructive tools conflict with read_only policy."""
        with pytest.raises(ValidationError) as exc_info:
            PersonaSpec(
                id="test-destructive-conflict",
                description="A test persona.",
                prompt="You are a test.",
                model="gpt-4",
                tools={"delete_file": "destructive"},
                side_effect_policy=SideEffectPolicy.read_only,
            )
        errors = exc_info.value.errors()
        assert any("ERROR #6" in str(err.get("msg", "")) for err in errors)

    # =============================================================================
    # Nullability Parity Tests (schema-sync.add-persona-nullability-parity-tests)
    # =============================================================================

    def test_id_kebab_case_accepts_valid_values(self) -> None:
        """id field accepts kebab-case values."""
        p = PersonaSpec(
            id="spec-abc123", description="A test.", prompt="Test", model="gpt-4"
        )
        assert p.id == "spec-abc123"

    def test_id_kebab_case_rejects_invalid_values(self) -> None:
        """id field rejects uppercase and spaces."""
        with pytest.raises(ValidationError):
            PersonaSpec(
                id="My Agent", description="A test.", prompt="Test", model="gpt-4"
            )

    def test_spec_digest_explicit_null(self) -> None:
        """spec_digest field accepts explicit None."""
        p = PersonaSpec(
            id="test",
            description="A test.",
            prompt="Test",
            model="gpt-4",
            spec_digest=None,
        )
        assert p.spec_digest is None
        dumped = p.model_dump(exclude_none=True)
        assert "spec_digest" not in dumped

    def test_spec_digest_explicit_string(self) -> None:
        """spec_digest field accepts non-null string."""
        p = PersonaSpec(
            id="test",
            description="A test.",
            prompt="Test",
            model="gpt-4",
            spec_digest="sha256:abc123",
        )
        assert p.spec_digest == "sha256:abc123"
        dumped = p.model_dump()
        assert dumped["spec_digest"] == "sha256:abc123"

    def test_spec_digest_omitted(self) -> None:
        """spec_digest field defaults to None when omitted."""
        p = PersonaSpec(id="test", description="A test.", prompt="Test", model="gpt-4")
        assert p.spec_digest is None
        dumped = p.model_dump(exclude_none=True)
        assert "spec_digest" not in dumped

    def test_all_three_nullable_fields_explicit_null(self) -> None:
        """All nullable alignment fields accept explicit None simultaneously."""
        p = PersonaSpec(
            id="test",
            description="A test.",
            prompt="Test",
            model="gpt-4",
            spec_digest=None,
        )
        assert p.spec_digest is None
        dumped = p.model_dump(exclude_none=True)
        # Only spec_digest should be excluded
        assert "spec_digest" not in dumped

    def test_all_three_nullable_fields_non_null(self) -> None:
        """All nullable alignment fields accept non-null values simultaneously."""
        p = PersonaSpec(
            id="test",
            description="A test.",
            prompt="Test",
            model="gpt-4",
            spec_digest="sha256:def456",
        )
        assert p.spec_digest == "sha256:def456"

    def test_json_schema_nullability_matches_model(self) -> None:
        """JSON schema nullable definition matches Pydantic model behavior."""
        import json

        # Load the JSON schema using portable path resolution
        schema_path = (
            Path(__file__).parent.parent / "contracts" / "persona_spec.schema.json"
        )
        with open(schema_path) as f:
            schema = json.load(f)

        def is_nullable(field_schema: dict) -> bool:
            """Check if a JSON schema field allows null."""
            # Case 1: type is an array like ["string", "null"]
            if isinstance(field_schema.get("type"), list):
                return "null" in field_schema["type"]
            # Case 2: oneOf contains a null type
            if "oneOf" in field_schema:
                return any(
                    isinstance(item, dict) and item.get("type") == "null"
                    for item in field_schema["oneOf"]
                )
            # Case 3: anyOf contains a null type
            if "anyOf" in field_schema:
                return any(
                    isinstance(item, dict) and item.get("type") == "null"
                    for item in field_schema["anyOf"]
                )
            return False

        # Verify schema declares spec_digest as nullable
        assert "spec_digest" in schema["properties"]
        assert is_nullable(schema["properties"]["spec_digest"])

    def test_spec_version_omission_contract_matches_schema_and_model(self) -> None:
        """spec_version omission is allowed and defaults to 0.1.0."""
        import json

        schema_path = (
            Path(__file__).parent.parent / "contracts" / "persona_spec.schema.json"
        )
        with open(schema_path) as f:
            schema = json.load(f)

        assert "spec_version" not in schema["required"]

        p = PersonaSpec(id="test", description="A test.", prompt="Test", model="gpt-4")
        assert p.spec_version == "0.1.0"

    def test_persona_with_all_fields(self) -> None:
        """PersonaSpec with all optional fields set."""
        p = PersonaSpec(
            id="worker",
            description="A worker persona.",
            prompt="You are a worker.",
            model="gpt-4",
            spec_version="0.1.0",
            tools={"write_file": "read_write"},
            can_spawn=["subworker", "helper"],
            spec_digest="sha256:1234",
        )
        assert p.can_spawn == ["subworker", "helper"]

    @pytest.mark.parametrize(
        "can_spawn,valid",
        [
            (False, True),
            (True, True),
            ([], True),
            (["worker"], True),
            (["a", "b", "c"], True),
            # Invalid cases need to be tested separately due to type issues
        ],
    )
    def test_can_spawn_valid_cases(self, can_spawn: CanSpawn, valid: bool) -> None:
        """can_spawn accepts bool or list of strings."""
        p = PersonaSpec(
            id="test",
            description="A test.",
            prompt="Test",
            model="gpt-4",
            can_spawn=can_spawn,
        )
        assert p.can_spawn == can_spawn

    def test_can_spawn_invalid_type(self) -> None:
        """can_spawn rejects non-bool/non-list types."""
        with pytest.raises(ValidationError):
            PersonaSpec(
                id="test",
                description="A test.",
                prompt="Test",
                model="gpt-4",
                can_spawn="invalid",  # type: ignore[arg-type]
            )

    def test_can_spawn_invalid_list_items(self) -> None:
        """can_spawn list must contain only strings."""
        with pytest.raises(ValidationError):
            PersonaSpec(
                id="test",
                description="A test.",
                prompt="Test",
                model="gpt-4",
                can_spawn=[1, 2, 3],  # type: ignore[arg-type]
            )

    def test_invalid_spec_version_rejected(self) -> None:
        """spec_version must be exactly '0.1.0'."""
        with pytest.raises(ValidationError):
            PersonaSpec(
                id="test",
                description="A test.",
                prompt="Test",
                model="gpt-4",
                spec_version="0.2.0",  # type: ignore[arg-type]
            )

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        with pytest.raises(ValidationError):
            PersonaSpec(
                id="test",
                description="A test.",
                prompt="Test",
                model="gpt-4",
                extra_field="bad",  # type: ignore
            )

    def test_legacy_persona_tools_profile_field_rejected(self) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        with pytest.raises(ValidationError):
            PersonaSpec(
                id="test",
                description="A test.",
                prompt="Test",
                model="gpt-4",
                tools_profile="default",  # type: ignore
            )

    def test_missing_required_fields(self) -> None:
        """Missing required fields raise ValidationError."""
        with pytest.raises(ValidationError):
            PersonaSpec(id="test", description="A test.", prompt="Test")  # type: ignore

    def test_validate_can_spawn_function_bool(self) -> None:
        """_validate_can_spawn accepts bool values."""
        assert _validate_can_spawn(False) is False
        assert _validate_can_spawn(True) is True

    def test_validate_can_spawn_function_list(self) -> None:
        """_validate_can_spawn accepts list of strings."""
        assert _validate_can_spawn([]) == []
        assert _validate_can_spawn(["a"]) == ["a"]
        assert _validate_can_spawn(["a", "b", "c"]) == ["a", "b", "c"]

    def test_validate_can_spawn_function_invalid(self) -> None:
        """_validate_can_spawn rejects invalid types."""
        with pytest.raises(ValueError, match="must be bool or list"):
            _validate_can_spawn("invalid")  # type: ignore

        with pytest.raises(ValueError, match="items must be strings"):
            _validate_can_spawn([1, 2])  # type: ignore

    # =============================================================================
    # PersonaSpec Constraint Enhancements (persona-field-renames.persona-constraint-enhancements)
    # =============================================================================

    def test_model_field_min_length_rejects_empty(self) -> None:
        """model field with min_length=1 rejects empty string."""
        with pytest.raises(ValidationError) as exc_info:
            PersonaSpec(id="x", model="", description="d", prompt="p")
        errors = exc_info.value.errors()
        assert any(err.get("loc") == ("model",) for err in errors)

    def test_model_field_min_length_accepts_non_empty(self) -> None:
        """model field accepts non-empty string."""
        p = PersonaSpec(id="x", model="m", description="d", prompt="p")
        assert p.model == "m"

    def test_can_spawn_unique_items_rejects_duplicates(self) -> None:
        """can_spawn list with uniqueItems rejects duplicate values."""
        with pytest.raises(ValidationError) as exc_info:
            PersonaSpec(
                id="x", model="m", description="d", prompt="p", can_spawn=["a", "a"]
            )
        errors = exc_info.value.errors()
        assert any("unique" in str(err.get("msg", "")).lower() for err in errors)

    def test_can_spawn_unique_items_accepts_unique(self) -> None:
        """can_spawn list accepts unique values."""
        p = PersonaSpec(
            id="x", model="m", description="d", prompt="p", can_spawn=["a", "b", "c"]
        )
        assert p.can_spawn == ["a", "b", "c"]

    def test_can_spawn_min_length_rejects_empty_item(self) -> None:
        """can_spawn list items must have min_length=1."""
        with pytest.raises(ValidationError) as exc_info:
            PersonaSpec(
                id="x", model="m", description="d", prompt="p", can_spawn=["", "b"]
            )
        errors = exc_info.value.errors()
        assert any(
            "min length" in str(err.get("msg", "")).lower()
            or "length of 1" in str(err.get("msg", "")).lower()
            for err in errors
        )

    def test_can_spawn_min_length_accepts_non_empty_items(self) -> None:
        """can_spawn list accepts items with at least 1 character."""
        p = PersonaSpec(
            id="x", model="m", description="d", prompt="p", can_spawn=["a", "b"]
        )
        assert p.can_spawn == ["a", "b"]


# =============================================================================
# JobSpec Tests
# =============================================================================


class TestJobSpec:
    """Tests for JobSpec validation."""

    def test_minimal_valid_job(self) -> None:
        """JobSpec with only required fields."""
        j = JobSpec(persona_ref="test@abc", instance_id="inst_123")
        assert j.persona_ref == "test@abc"
        assert j.instance_id == "inst_123"
        assert j.job_version == "0.1.0"
        assert j.lifecycle == LifecycleType.ephemeral
        assert j.completion_policy == CompletionPolicy.explicit
        assert j.finalize_grace_budget == 500
        assert j.max_depth == 3

    def test_job_with_all_fields(self) -> None:
        """JobSpec with all optional fields set."""
        j = JobSpec(
            persona_ref="p@v1",
            instance_id="inst_456",
            job_version="0.1.0",
            context_bundle_ref="bundle_xyz",
            timeout="30s",
            max_turns=10,
            max_tokens=1000,
            completion_policy=CompletionPolicy.auto,
            lifecycle=LifecycleType.daemon,
            context_mode=ContextMode.shared,
            side_effect_policy=SideEffectPolicy.read_only,
            finalize_grace_budget=100,
            max_depth=5,
        )
        assert j.timeout == "30s"
        assert j.max_turns == 10
        assert j.max_tokens == 1000
        assert j.completion_policy == CompletionPolicy.auto
        assert j.lifecycle == LifecycleType.daemon
        assert j.context_mode == ContextMode.shared
        assert j.side_effect_policy == SideEffectPolicy.read_only

    @pytest.mark.parametrize(
        "max_turns,valid",
        [
            (None, True),
            (1, True),
            (100, True),
            (0, False),
            (-1, False),
        ],
    )
    def test_max_turns_validation(self, max_turns: int | None, valid: bool) -> None:
        """max_turns must be >= 1 if set."""
        if valid:
            j = JobSpec(persona_ref="p@v1", instance_id="i1", max_turns=max_turns)
            assert j.max_turns == max_turns
        else:
            with pytest.raises(ValidationError):
                JobSpec(persona_ref="p@v1", instance_id="i1", max_turns=max_turns)

    @pytest.mark.parametrize(
        "max_tokens,valid",
        [
            (None, True),
            (1, True),
            (1000, True),
            (0, False),
            (-1, False),
        ],
    )
    def test_max_tokens_validation(self, max_tokens: int | None, valid: bool) -> None:
        """max_tokens must be >= 1 if set."""
        if valid:
            j = JobSpec(persona_ref="p@v1", instance_id="i1", max_tokens=max_tokens)
            assert j.max_tokens == max_tokens
        else:
            with pytest.raises(ValidationError):
                JobSpec(persona_ref="p@v1", instance_id="i1", max_tokens=max_tokens)

    @pytest.mark.parametrize(
        "finalize_grace_budget,valid",
        [
            (0, True),
            (500, True),
            (1000, True),
            (-1, False),
            (-100, False),
        ],
    )
    def test_finalize_grace_budget_validation(
        self, finalize_grace_budget: int, valid: bool
    ) -> None:
        """finalize_grace_budget must be >= 0."""
        if valid:
            j = JobSpec(
                persona_ref="p@v1",
                instance_id="i1",
                finalize_grace_budget=finalize_grace_budget,
            )
            assert j.finalize_grace_budget == finalize_grace_budget
        else:
            with pytest.raises(ValidationError):
                JobSpec(
                    persona_ref="p@v1",
                    instance_id="i1",
                    finalize_grace_budget=finalize_grace_budget,
                )

    @pytest.mark.parametrize(
        "max_depth,valid",
        [
            (1, True),
            (3, True),
            (10, True),
            (0, False),
            (-1, False),
        ],
    )
    def test_max_depth_validation(self, max_depth: int, valid: bool) -> None:
        """max_depth must be >= 1."""
        if valid:
            j = JobSpec(persona_ref="p@v1", instance_id="i1", max_depth=max_depth)
            assert j.max_depth == max_depth
        else:
            with pytest.raises(ValidationError):
                JobSpec(persona_ref="p@v1", instance_id="i1", max_depth=max_depth)

    def test_invalid_job_version_rejected(self) -> None:
        """job_version must be exactly '0.1.0'."""
        with pytest.raises(ValidationError):
            JobSpec(persona_ref="p@v1", instance_id="i1", job_version="0.2.0")  # type: ignore[arg-type]

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        with pytest.raises(ValidationError):
            JobSpec(persona_ref="p@v1", instance_id="i1", extra="bad")  # type: ignore

    def test_context_mode_isolated_rejects_context_bundle_ref(self) -> None:
        """context_mode='isolated' conflicts with context_bundle_ref being set.

        Spec: vectl step wire-or-document-jobspec-context-mode
        Verification: Schema validation rejects isolated + bundle_ref.
        """
        with pytest.raises(ValidationError) as exc_info:
            JobSpec(
                persona_ref="p@v1",
                instance_id="i1",
                context_mode=ContextMode.isolated,
                context_bundle_ref="/path/to/bundle.json",
            )

        # Verify the error message mentions the conflict
        errors = exc_info.value.errors()
        assert any(
            "context_mode='isolated' conflicts with context_bundle_ref"
            in str(err.get("msg", ""))
            for err in errors
        ), f"Expected conflict error, got: {errors}"

    def test_context_mode_shared_allows_context_bundle_ref(self) -> None:
        """context_mode='shared' allows context_bundle_ref to be set.

        Spec: vectl step wire-or-document-jobspec-context-mode
        Verification: Schema accepts shared + bundle_ref.
        """
        j = JobSpec(
            persona_ref="p@v1",
            instance_id="i1",
            context_mode=ContextMode.shared,
            context_bundle_ref="/path/to/bundle.json",
        )
        assert j.context_mode == ContextMode.shared
        assert j.context_bundle_ref == "/path/to/bundle.json"

    def test_context_mode_isolated_without_bundle_ref_is_valid(self) -> None:
        """context_mode='isolated' without bundle_ref is the default state."""
        j = JobSpec(
            persona_ref="p@v1",
            instance_id="i1",
            context_mode=ContextMode.isolated,
        )
        assert j.context_mode == ContextMode.isolated
        assert j.context_bundle_ref is None

    def test_default_context_mode_is_isolated(self) -> None:
        """Default context_mode should be isolated."""
        j = JobSpec(persona_ref="p@v1", instance_id="i1")
        assert j.context_mode == ContextMode.isolated

    def test_side_effect_policy_schema_mentions_most_restrictive_ordering(self) -> None:
        """JobSpec side_effect_policy schema documents merge ordering."""
        schema = JobSpec.model_json_schema()
        description = schema["properties"]["side_effect_policy"]["description"]
        assert "most_restrictive" in description
        assert "read_only > approval_required > allow" in description

    def test_persona_ref_schema_description(self) -> None:
        """JobSpec persona_ref field has schema description."""
        schema = JobSpec.model_json_schema()
        description = schema["properties"]["persona_ref"]["description"]
        assert "persona identity reference" in description.lower()


# =============================================================================
# ContextBundle Tests
# =============================================================================


class TestRawTailEntry:
    """Tests for RawTailEntry validation."""

    def test_minimal_entry(self) -> None:
        """RawTailEntry with required fields only."""
        e = RawTailEntry(role="user", content="Hello")
        assert e.role == "user"
        assert e.content == "Hello"
        assert e.speaker_id is None

    def test_entry_with_speaker_id(self) -> None:
        """RawTailEntry with speaker_id."""
        e = RawTailEntry(role="assistant", speaker_id="inst_123", content="Hi there")
        assert e.speaker_id == "inst_123"

    @pytest.mark.parametrize("role", ["user", "assistant", "tool"])
    def test_valid_roles(self, role: str) -> None:
        """Valid roles are accepted."""
        from typing import Literal, cast

        e = RawTailEntry(
            role=cast(Literal["user", "assistant", "tool"], role), content="Test"
        )
        assert e.role == role

    def test_invalid_role_rejected(self) -> None:
        """Invalid roles are rejected."""
        with pytest.raises(ValidationError):
            RawTailEntry(role="system", content="Test")  # type: ignore[arg-type]

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        with pytest.raises(ValidationError):
            RawTailEntry(role="user", content="Hi", extra="bad")  # type: ignore


class TestSummaryRef:
    """Tests for SummaryRef validation."""

    def test_minimal_summary(self) -> None:
        """SummaryRef with required fields only."""
        s = SummaryRef(
            summary_id="sum_abc",
            model_id="claude-sonnet-4-20250514",
            created_at=datetime(2026, 2, 28, 10, 30, 0),
        )
        assert s.summary_id == "sum_abc"
        assert s.model_id == "claude-sonnet-4-20250514"
        assert s.prompt_digest is None
        assert s.input_digest is None

    def test_summary_with_all_fields(self) -> None:
        """SummaryRef with all optional fields."""
        s = SummaryRef(
            summary_id="sum_xyz",
            model_id="gpt-4",
            prompt_digest="sha256:aabb",
            input_digest="sha256:ccdd",
            created_at=datetime(2026, 3, 1, 12, 0, 0),
        )
        assert s.prompt_digest == "sha256:aabb"
        assert s.input_digest == "sha256:ccdd"


class TestContextBundle:
    """Tests for ContextBundle validation."""

    def test_minimal_bundle(self) -> None:
        """ContextBundle with only bundle_id."""
        b = ContextBundle(bundle_id="bundle_xyz")
        assert b.bundle_id == "bundle_xyz"
        assert b.inherited_from is None
        assert b.raw_tail is None
        assert b.summary_refs is None
        assert b.artifact_refs is None

    def test_bundle_with_all_fields(self) -> None:
        """ContextBundle with all optional fields."""
        b = ContextBundle(
            bundle_id="bundle_full",
            inherited_from="inst_parent",
            raw_tail=[RawTailEntry(role="user", content="Hi")],
            summary_refs=[
                SummaryRef(
                    summary_id="s1",
                    model_id="m1",
                    created_at=datetime(2026, 1, 1),
                )
            ],
            artifact_refs=["plan.json", "state.json"],
        )
        assert b.inherited_from == "inst_parent"
        assert b.raw_tail is not None and len(b.raw_tail) == 1
        assert b.summary_refs is not None and len(b.summary_refs) == 1
        assert b.artifact_refs == ["plan.json", "state.json"]

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        with pytest.raises(ValidationError):
            ContextBundle(bundle_id="b1", extra="bad")  # type: ignore


# =============================================================================
# CapabilityToken Tests
# =============================================================================


class TestCapabilityToken:
    """Tests for CapabilityToken validation."""

    def test_tools_profile_allowed_for_capability_token_forbidden_for_persona_spec(
        self,
    ) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers-loop2."""
        token = CapabilityToken(
            token_id="tok_class_split",
            tools_profile="default",
            issued_at=datetime(2026, 2, 28, 10, 0, 0),
            expires_at=datetime(2026, 2, 28, 11, 0, 0),
            signature="sig_class_split",
        )
        assert token.tools_profile == "default"

        with pytest.raises(ValidationError):
            PersonaSpec(
                id="persona_forbidden",
                description="A test.",
                prompt="Test",
                model="gpt-4",
                tools_profile="default",  # type: ignore[arg-type]
            )

    def test_tools_profile_is_capability_token_compatibility_exception(self) -> None:
        """Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers."""
        t = CapabilityToken(
            token_id="tok_exception",
            tools_profile="default",
            issued_at=datetime(2026, 2, 28, 10, 0, 0),
            expires_at=datetime(2026, 2, 28, 11, 0, 0),
            signature="sig_exception",
        )
        assert t.tools_profile == "default"

    def test_minimal_token(self) -> None:
        """CapabilityToken with required fields only."""
        t = CapabilityToken(
            token_id="tok_abc123",
            tools_profile="default",
            issued_at=datetime(2026, 2, 28, 10, 0, 0),
            expires_at=datetime(2026, 2, 28, 11, 0, 0),
            signature="sig_xyz",
        )
        assert t.token_id == "tok_abc123"
        assert t.tools_profile == "default"
        assert t.max_depth is None

    def test_token_with_all_fields(self) -> None:
        """CapabilityToken with all optional fields."""
        t = CapabilityToken(
            token_id="tok_full",
            tools_profile="full",
            persona_ref="worker@v1",
            instance_id="inst_123",
            max_depth=5,
            issued_at=datetime(2026, 3, 1, 0, 0, 0),
            expires_at=datetime(2026, 3, 1, 1, 0, 0),
            signature="sig_full",
        )
        assert t.max_depth == 5

    @pytest.mark.parametrize(
        "token_id,valid",
        [
            ("tok_", True),
            ("tok_abc", True),
            ("tok_123xyz", True),
            ("bad_token", False),
            ("TOK_uppercase", False),
            ("", False),
        ],
    )
    def test_token_id_pattern(self, token_id: str, valid: bool) -> None:
        """token_id must match pattern ^tok_."""
        if valid:
            t = CapabilityToken(
                token_id=token_id,
                tools_profile="default",
                issued_at=datetime.now(),
                expires_at=datetime.now(),
                signature="sig",
            )
            assert t.token_id == token_id
        else:
            with pytest.raises(ValidationError):
                CapabilityToken(
                    token_id=token_id,
                    tools_profile="default",
                    issued_at=datetime.now(),
                    expires_at=datetime.now(),
                    signature="sig",
                )

    def test_budget_field_rejected(self) -> None:
        """CapabilityToken rejects removed budget field."""
        with pytest.raises(ValidationError):
            CapabilityToken(
                token_id="tok_test",
                tools_profile="default",
                budget=100,  # type: ignore[call-arg]
                issued_at=datetime.now(),
                expires_at=datetime.now(),
                signature="sig",
            )

    def test_persona_ref_schema_description(self) -> None:
        """CapabilityToken persona_ref field has schema description."""
        schema = CapabilityToken.model_json_schema()
        description = schema["properties"]["persona_ref"]["description"]
        assert "persona identity reference" in description.lower()

    @pytest.mark.parametrize(
        "max_depth,valid",
        [
            (None, True),
            (0, True),
            (5, True),
            (-1, False),
        ],
    )
    def test_max_depth_validation(self, max_depth: int | None, valid: bool) -> None:
        """max_depth must be >= 0 if set."""
        if valid:
            t = CapabilityToken(
                token_id="tok_test",
                tools_profile="default",
                max_depth=max_depth,
                issued_at=datetime.now(),
                expires_at=datetime.now(),
                signature="sig",
            )
            assert t.max_depth == max_depth
        else:
            with pytest.raises(ValidationError):
                CapabilityToken(
                    token_id="tok_test",
                    tools_profile="default",
                    max_depth=max_depth,
                    issued_at=datetime.now(),
                    expires_at=datetime.now(),
                    signature="sig",
                )

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        with pytest.raises(ValidationError):
            CapabilityToken(
                token_id="tok_test",
                tools_profile="default",
                issued_at=datetime.now(),
                expires_at=datetime.now(),
                signature="sig",
                extra="bad",  # type: ignore
            )


# =============================================================================
# TokenUsage Tests
# =============================================================================


class TestTokenUsage:
    """Tests for TokenUsage validation."""

    def test_default_values(self) -> None:
        """TokenUsage defaults to zero tokens."""
        u = TokenUsage()
        assert u.prompt_tokens == 0
        assert u.completion_tokens == 0
        assert u.total_tokens == 0

    def test_total_tokens_calculation(self) -> None:
        """total_tokens equals sum of prompt and completion tokens."""
        u = TokenUsage(prompt_tokens=100, completion_tokens=50)
        assert u.total_tokens == 150

        u.prompt_tokens = 200
        assert u.total_tokens == 250

        u.completion_tokens = 100
        assert u.total_tokens == 300

    @pytest.mark.parametrize(
        "prompt_tokens,completion_tokens,valid",
        [
            (0, 0, True),
            (100, 50, True),
            (0, 100, True),
            (100, 0, True),
            (-1, 0, False),
            (0, -1, False),
            (-1, -1, False),
        ],
    )
    def test_non_negative_constraint(
        self, prompt_tokens: int, completion_tokens: int, valid: bool
    ) -> None:
        """Token counts must be non-negative."""
        if valid:
            u = TokenUsage(
                prompt_tokens=prompt_tokens, completion_tokens=completion_tokens
            )
            assert u.prompt_tokens == prompt_tokens
            assert u.completion_tokens == completion_tokens
        else:
            with pytest.raises(ValidationError):
                TokenUsage(
                    prompt_tokens=prompt_tokens, completion_tokens=completion_tokens
                )


# =============================================================================
# AgentStateData Tests
# =============================================================================


class TestAgentStateData:
    """Tests for AgentStateData validation."""

    def test_minimal_state(self) -> None:
        """AgentStateData with required fields only."""
        a = AgentStateData(
            instance_id="inst_abc",
            persona_name="test",
            trace_id="tr_xyz",
        )
        assert a.agent_id == "inst_abc"  # instance_id is derived from instance_id
        assert a.instance_id == "inst_abc"
        assert a.trace_id == "tr_xyz"
        assert a.persona_name == "test"
        assert a.state == AgentState.pending
        assert a.lifecycle == LifecycleType.ephemeral
        assert a.parent_id is None
        assert a.children == []
        assert a.depth == 0
        assert a.pid is None
        assert a.result is None
        assert a.token_usage.total_tokens == 0

    def test_state_with_all_fields(self) -> None:
        """AgentStateData with all optional fields."""
        a = AgentStateData(
            instance_id="inst_xyz",
            trace_id="tr_abc",
            persona_name="worker",
            state=AgentState.running,
            lifecycle=LifecycleType.daemon,
            parent_id="parent_123",
            children=["child_1", "child_2"],
            depth=2,
            pid=12345,
            mcp_socket="/tmp/mcp.sock",
            created_at="2026-03-01T00:00:00Z",
            updated_at="2026-03-01T01:00:00Z",
            token_usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
            tools_called=[ToolCallCount(name="read_file", count=5)],
            current_turn=3,
            max_turns=10,
            budget_total=100000,
            grace_budget=200,
            error=None,
            result_ref="results/inst_xyz.json",
            result=AgentResult(final_state=AgentState.completed),
        )
        assert a.state == AgentState.running
        assert a.lifecycle == LifecycleType.daemon
        assert a.parent_id == "parent_123"
        assert len(a.children) == 2
        assert a.depth == 2
        assert a.pid == 12345
        assert a.token_usage.total_tokens == 150
        assert a.tools_called is not None and len(a.tools_called) == 1

    def test_state_transition(self) -> None:
        """AgentStateData state can be updated."""
        a = AgentStateData(instance_id="i1", persona_name="p", trace_id="tr_1")
        a.state = AgentState.running
        assert a.state == AgentState.running
        a.state = AgentState.completed
        assert a.state == AgentState.completed

    def test_with_result(self) -> None:
        """AgentStateData can have a result."""
        a = AgentStateData(
            instance_id="i1",
            persona_name="p",
            trace_id="tr_1",
            state=AgentState.completed,
            result=AgentResult(
                final_state=AgentState.completed,
                result_ref="results/xyz.json",
                token_usage=TokenUsage(prompt_tokens=50, completion_tokens=25),
            ),
        )
        assert a.result is not None
        assert a.result.final_state == AgentState.completed
        assert a.result.token_usage is not None
        assert a.result.token_usage.total_tokens == 75

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        with pytest.raises(ValidationError):
            AgentStateData.model_validate(
                {
                    "instance_id": "i1",
                    "persona_name": "p",
                    "trace_id": "tr_1",
                    "extra": "bad",
                }
            )

    def test_legacy_persona_id_maps_to_persona_name(self) -> None:
        """Legacy persona_id input maps to canonical persona_name."""
        state = AgentStateData.model_validate(
            {
                "instance_id": "legacy_inst",
                "trace_id": "tr_legacy",
                "persona_id": "legacy_persona",
            }
        )
        assert state.persona_name == "legacy_persona"

    def test_conflicting_persona_fields_fail_validation(self) -> None:
        """Conflicting persona_id and persona_name are rejected."""
        with pytest.raises(ValidationError):
            AgentStateData.model_validate(
                {
                    "instance_id": "legacy_inst",
                    "trace_id": "tr_legacy",
                    "persona_id": "old_name",
                    "persona_name": "new_name",
                }
            )


# =============================================================================
# AnimaError and ERROR_CODES Tests
# =============================================================================


class TestAnimaError:
    """Tests for AnimaError base class."""

    def test_basic_error(self) -> None:
        """AnimaError with code and message."""
        e = AnimaError(code=1, message="Invalid input")
        assert e.code == 1
        assert e.message == "Invalid input"
        assert e.details is None

    def test_error_with_details(self) -> None:
        """AnimaError with details dict."""
        e = AnimaError(code=5, message="Failed", details={"key": "value"})
        assert e.details == {"key": "value"}

    def test_str_representation(self) -> None:
        """AnimaError string format is [code] message."""
        e = AnimaError(code=5, message="Test error")
        assert str(e) == "[5] Test error"

    def test_code_must_be_positive(self) -> None:
        """AnimaError code must be >= 1."""
        with pytest.raises(Exception):  # PreContractError from deal
            AnimaError(code=0, message="invalid")  # pyright: ignore[reportArgumentType]

    @pytest.mark.parametrize(
        "error_class,expected_code",
        [
            (InvalidInputError, 1),
            (NotFoundError, 2),
            (AnimaTimeoutError, 3),
            (InternalError, 10),
            (AgentNotFoundError, 300),
            (AgentAlreadyExistsError, 301),
            (BudgetExhaustedError, 310),
            (MaxTurnsReachedError, 311),
            (MaxDepthExceededError, 312),
            (AgentTerminatedError, 313),
            (AgentNotInteractiveError, 314),
            (AgentNotTerminalError, 315),
            (MCPSocketNotFoundError, 321),
            (MCPConnectionRefusedError, 322),
            (MCPConnectionError, 323),
            (UnsupportedModelError, 320),
        ],
    )
    def test_error_codes(
        self, error_class: type[AnimaError], expected_code: int
    ) -> None:
        """Each error subclass has correct code."""
        e = error_class("Test message")  # pyright: ignore[reportCallIssue,reportAbstractUsage]
        assert e.code == expected_code

    def test_error_with_details_subclass(self) -> None:
        """Error subclasses can have details."""
        e = BudgetExhaustedError(
            "Budget exhausted", details={"used": 10000, "limit": 10000}
        )
        assert e.details == {"used": 10000, "limit": 10000}

    def test_build_error_envelope_uses_numeric_and_string_codes(self) -> None:
        """Error envelope includes consistent code and numeric_code fields."""
        envelope = build_error_envelope(AgentNotFoundError("Agent missing"))

        assert envelope["error"]["code"] == "AGENT_NOT_FOUND"
        assert envelope["error"]["numeric_code"] == 300
        assert envelope["error"]["message"] == "Agent missing"
        assert envelope["error"]["details"] is None

    def test_build_error_envelope_preserves_details(self) -> None:
        """Error envelope carries details object unchanged."""
        envelope = build_error_envelope(
            AgentNotInteractiveError(
                "Agent not running",
                details={"instance_id": "ag_123", "current_state": "completed"},
            )
        )

        assert envelope["error"]["code"] == "AGENT_NOT_INTERACTIVE"
        assert envelope["error"]["numeric_code"] == 314
        assert envelope["error"]["details"] == {
            "instance_id": "ag_123",
            "current_state": "completed",
        }


class TestErrorCodes:
    """Tests for ERROR_CODES registry."""

    def test_error_codes_registry_complete(self) -> None:
        """ERROR_CODES contains all expected error classes."""
        expected_codes = {
            1: InvalidInputError,
            2: NotFoundError,
            3: AnimaTimeoutError,
            10: InternalError,
            300: AgentNotFoundError,
            301: AgentAlreadyExistsError,
            310: BudgetExhaustedError,
            311: MaxTurnsReachedError,
            312: MaxDepthExceededError,
            313: AgentTerminatedError,
            314: AgentNotInteractiveError,
            315: AgentNotTerminalError,
            320: UnsupportedModelError,
            321: MCPSocketNotFoundError,
            322: MCPConnectionRefusedError,
            323: MCPConnectionError,
            330: ToolBlockedError,
            331: ApprovalRequiredError,
        }
        assert ERROR_CODES == expected_codes

    def test_lookup_by_code(self) -> None:
        """Can look up error class by code."""
        assert ERROR_CODES[1] == InvalidInputError
        assert ERROR_CODES[300] == AgentNotFoundError
        assert ERROR_CODES[310] == BudgetExhaustedError

    @pytest.mark.parametrize(
        "code,error_class,message",
        [
            (1, InvalidInputError, "Missing field"),
            (2, NotFoundError, "Not found"),
            (310, BudgetExhaustedError, "Budget exceeded"),
        ],
    )
    def test_create_via_registry(
        self, code: int, error_class: type[AnimaError], message: str
    ) -> None:
        """Can create error instance via registry lookup."""
        cls = ERROR_CODES[code]
        e = cls(message)  # pyright: ignore[reportCallIssue,reportAbstractUsage]
        assert isinstance(e, error_class)
        assert e.code == code
        assert e.message == message


# =============================================================================
# Additional Model Tests
# =============================================================================


class TestMeta:
    """Tests for Meta model."""

    def test_minimal_meta(self) -> None:
        """Meta with trace_id only."""
        m = Meta(trace_id="trace_abc")
        assert m.trace_id == "trace_abc"
        assert m.event_id is None
        assert m.idempotency_key is None

    def test_meta_with_all_fields(self) -> None:
        """Meta with all optional fields."""
        m = Meta(
            trace_id="trace_xyz",
            event_id="evt_1",
            idempotency_key="key_abc",
            instance_id="inst_123",
        )
        assert m.event_id == "evt_1"
        assert m.idempotency_key == "key_abc"

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        with pytest.raises(ValidationError):
            Meta(trace_id="t1", extra="bad")  # type: ignore[reportCallIssue]


class TestAgentResult:
    """Tests for AgentResult model."""

    def test_minimal_result(self) -> None:
        """AgentResult with final_state only."""
        r = AgentResult(final_state=AgentState.completed)
        assert r.final_state == AgentState.completed
        assert r.result_ref is None
        assert r.token_usage is None
        assert r.error is None

    def test_result_with_all_fields(self) -> None:
        """AgentResult with all optional fields."""
        r = AgentResult(
            result_ref="results/inst_123.json",
            final_state=AgentState.failed,
            token_usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
            error="Budget exhausted",
        )
        assert r.result_ref == "results/inst_123.json"
        assert r.token_usage is not None and r.token_usage.total_tokens == 150
        assert r.error == "Budget exhausted"


class TestToolCallCount:
    """Tests for ToolCallCount model."""

    def test_tool_call_count(self) -> None:
        """ToolCallCount tracks name and count."""
        t = ToolCallCount(name="read_file", count=5)
        assert t.name == "read_file"
        assert t.count == 5

    def test_default_count(self) -> None:
        """ToolCallCount defaults count to 0."""
        t = ToolCallCount(name="write_file")
        assert t.count == 0


class TestLogEntry:
    """Tests for LogEntry model."""

    def test_minimal_entry(self) -> None:
        """LogEntry with required fields only."""
        e = LogEntry(ts="2026-03-01T10:00:00Z", event="created")
        assert e.ts == "2026-03-01T10:00:00Z"
        assert e.event == "created"
        assert e.details is None

    def test_entry_with_details(self) -> None:
        """LogEntry with details dict."""
        e = LogEntry(
            ts="2026-03-01T10:00:00Z",
            event="state_transition",
            details={"from": "pending", "to": "running"},
        )
        assert e.details == {"from": "pending", "to": "running"}


class TestToolPresetConfig:
    """Tests for ToolPresetConfig model."""

    def test_minimal_preset(self) -> None:
        """ToolPresetConfig with command only."""
        tp = ToolPresetConfig(command="mytool")
        assert tp.command == "mytool"
        assert tp.args == []
        assert tp.env == {}
        assert tp.description == ""
        assert tp.sandbox is False
        assert tp.sandbox_allow == []

    def test_preset_with_all_fields(self) -> None:
        """ToolPresetConfig with all fields."""
        tp = ToolPresetConfig(
            command="python",
            args=["-m", "mytool"],
            env={"DEBUG": "1"},
            description="My custom tool",
            sandbox=True,
            sandbox_allow=["fs:/tmp", "net:example.com"],
        )
        assert tp.args == ["-m", "mytool"]
        assert tp.env == {"DEBUG": "1"}
        assert tp.description == "My custom tool"
        assert tp.sandbox is True
        assert tp.sandbox_allow == ["fs:/tmp", "net:example.com"]

    def test_sandbox_defaults_to_false(self) -> None:
        """sandbox defaults to False."""
        tp = ToolPresetConfig(command="x")
        assert tp.sandbox is False

    def test_sandbox_allow_defaults_to_empty_list(self) -> None:
        """sandbox_allow defaults to empty list."""
        tp = ToolPresetConfig(command="x")
        assert tp.sandbox_allow == []

    def test_sandbox_with_allow_list(self) -> None:
        """sandbox=True with sandbox_allow list."""
        tp = ToolPresetConfig(command="x", sandbox=True, sandbox_allow=["fs:/tmp"])
        assert tp.sandbox is True
        assert tp.sandbox_allow == ["fs:/tmp"]

    def test_empty_command_rejected(self) -> None:
        """Empty command is rejected."""
        with pytest.raises(ValidationError):
            ToolPresetConfig(command="")  # type: ignore

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        with pytest.raises(ValidationError):
            ToolPresetConfig(command="echo", extra="bad")  # type: ignore


class TestCompactionConfig:
    """Tests for CompactionConfig model.

    Spec source: context-compaction.add-compaction-config-schema
    """

    def test_default_values(self) -> None:
        """CompactionConfig has correct defaults."""
        from anima.core.schemas import CompactionConfig

        cc = CompactionConfig()
        assert cc.auto is True
        assert cc.prune is True
        assert cc.reserved == 20000
        assert cc.prompt is None

    def test_all_fields(self) -> None:
        """CompactionConfig with all fields set."""
        from anima.core.schemas import CompactionConfig

        cc = CompactionConfig(auto=False, prune=False, reserved=50000, prompt="Custom")
        assert cc.auto is False
        assert cc.prune is False
        assert cc.reserved == 50000
        assert cc.prompt == "Custom"

    @pytest.mark.parametrize(
        "reserved,valid",
        [
            (0, True),
            (1, True),
            (20000, True),
            (100000, True),
            (-1, False),
            (-100, False),
        ],
    )
    def test_reserved_validation(self, reserved: int, valid: bool) -> None:
        """reserved must be >= 0."""
        from anima.core.schemas import CompactionConfig

        if valid:
            cc = CompactionConfig(reserved=reserved)
            assert cc.reserved == reserved
        else:
            with pytest.raises(ValidationError):
                CompactionConfig(reserved=reserved)

    def test_extra_fields_forbidden(self) -> None:
        """Extra fields are rejected."""
        from anima.core.schemas import CompactionConfig

        with pytest.raises(ValidationError):
            CompactionConfig(extra="bad")  # type: ignore


class TestAnimaGlobalConfigCompaction:
    """Tests for AnimaGlobalConfig.compaction field.

    Spec source: context-compaction.add-compaction-config-schema
    """

    def test_default_compaction(self) -> None:
        """AnimaGlobalConfig has default compaction config."""
        cfg = AnimaGlobalConfig()
        assert cfg.compaction.auto is True
        assert cfg.compaction.prune is True
        assert cfg.compaction.reserved == 20000
        assert cfg.compaction.prompt is None

    def test_custom_compaction(self) -> None:
        """AnimaGlobalConfig accepts custom compaction config."""
        from anima.core.schemas import CompactionConfig

        cfg = AnimaGlobalConfig(
            compaction=CompactionConfig(auto=False, reserved=50000, prompt="Custom")
        )
        assert cfg.compaction.auto is False
        assert cfg.compaction.reserved == 50000
        assert cfg.compaction.prompt == "Custom"

    def test_compaction_dict_initialization(self) -> None:
        """AnimaGlobalConfig accepts dict for compaction field."""
        cfg = AnimaGlobalConfig(compaction={"auto": False, "reserved": 30000})  # type: ignore[arg-type]
        assert cfg.compaction.auto is False
        assert cfg.compaction.reserved == 30000


class TestAnimaGlobalConfigToolOutput:
    """Tests for AnimaGlobalConfig.tool_output section."""

    def test_default_tool_output_config(self) -> None:
        """AnimaGlobalConfig exposes default tool_output values."""
        cfg = AnimaGlobalConfig()

        assert cfg.tool_output.profile is None
        assert cfg.tool_output.per_call_cap_bytes is None
        assert cfg.tool_output.per_turn_cap_bytes is None
        assert cfg.tool_output.history_strategy == "summary_placeholder"
        assert cfg.tool_output.provider_profiles == {}
        assert cfg.tool_output.tool_defaults == {}

    def test_tool_output_governance_nested_dict_is_validated(self) -> None:
        """AnimaGlobalConfig validates tool_output nested structures."""
        cfg = AnimaGlobalConfig(
            tool_output={
                "profile": "default",
                "per_call_cap_bytes": 120000,
                "per_turn_cap_bytes": 240000,
                "provider_profiles": {
                    "default": {
                        "hard_cap_per_call_bytes": 120000,
                        "hard_cap_per_turn_bytes": 240000,
                        "prefers_pagination": True,
                    }
                },
                "tool_defaults": {
                    "directory_tree": {
                        "strategy": "paginate_then_excerpt",
                        "soft_cap_bytes": 80000,
                        "max_items": 200,
                        "max_depth": 4,
                        "preserve_structured_excerpt": True,
                    }
                },
            }
        )

        assert cfg.tool_output.profile == "default"
        assert cfg.tool_output.per_call_cap_bytes == 120000
        assert cfg.tool_output.per_turn_cap_bytes == 240000
        assert cfg.tool_output.history_strategy == "summary_placeholder"
        assert "default" in cfg.tool_output.provider_profiles
        assert (
            cfg.tool_output.provider_profiles["default"].hard_cap_per_call_bytes
            == 120000
        )
        assert cfg.tool_output.provider_profiles["default"].prefers_pagination is True
        assert "directory_tree" in cfg.tool_output.tool_defaults
        assert (
            cfg.tool_output.tool_defaults["directory_tree"].strategy
            == "paginate_then_excerpt"
        )

    @pytest.mark.parametrize(
        "payload",
        [
            {"tool_output": {"per_call_cap_bytes": 0}},
            {"tool_output": {"per_call_cap_bytes": -1}},
            {"tool_output": {"per_turn_cap_bytes": 0}},
            {"tool_output": {"per_turn_cap_bytes": -1}},
            {
                "tool_output": {
                    "provider_profiles": {
                        "openai": {
                            "hard_cap_per_call_bytes": 0,
                            "hard_cap_per_turn_bytes": 1,
                        }
                    }
                }
            },
            {
                "tool_output": {
                    "provider_profiles": {
                        "openai": {
                            "hard_cap_per_call_bytes": 1,
                            "hard_cap_per_turn_bytes": -1,
                        }
                    }
                }
            },
            {
                "tool_output": {
                    "tool_defaults": {
                        "directory_tree": {
                            "soft_cap_bytes": -10,
                        }
                    }
                }
            },
        ],
    )
    def test_tool_output_rejects_zero_or_negative_budget_values(
        self,
        payload: dict[str, object],
    ) -> None:
        """Per-call/per-turn/profile caps and soft caps reject zero/negative values."""
        with pytest.raises(ValidationError):
            AnimaGlobalConfig(**payload)

    def test_tool_output_rejects_unknown_history_strategy(self) -> None:
        """history_strategy accepts only contract-defined values."""
        with pytest.raises(ValidationError):
            AnimaGlobalConfig(tool_output={"history_strategy": "rollup"})

    def test_tool_output_rejects_unknown_tool_default_fields(self) -> None:
        """Unknown keys under tool_defaults.<tool> are rejected."""
        with pytest.raises(ValidationError):
            AnimaGlobalConfig(
                tool_output={
                    "tool_defaults": {
                        "directory_tree": {
                            "unknown_field": 123,
                        }
                    }
                }
            )

    def test_tool_output_schema_contract_names_required_knobs(self) -> None:
        """Schema declares governance knobs for caps, profiles, and defaults."""
        schema = AnimaGlobalConfig.model_json_schema()
        root_props = schema["properties"]

        assert "tool_output" in root_props

        tool_output_ref = root_props["tool_output"]["$ref"]
        tool_output_key = tool_output_ref.split("/")[-1]
        tool_output_schema = schema["$defs"][tool_output_key]
        tool_output_props = tool_output_schema["properties"]

        assert "profile" in tool_output_props
        assert "per_call_cap_bytes" in tool_output_props
        assert "per_turn_cap_bytes" in tool_output_props
        assert "history_strategy" in tool_output_props
        assert "provider_profiles" in tool_output_props
        assert "model_profiles" in tool_output_props
        assert "tool_defaults" in tool_output_props

    def test_tool_output_schema_contract_conservative_defaults(self) -> None:
        """Provider/model defaults stay conservative and never expand by default."""
        schema = AnimaGlobalConfig.model_json_schema()

        tool_output_ref = schema["properties"]["tool_output"]["$ref"]
        tool_output_schema = schema["$defs"][tool_output_ref.split("/")[-1]]

        provider_ref = tool_output_schema["properties"]["provider_profiles"][
            "additionalProperties"
        ]["$ref"]
        provider_schema = schema["$defs"][provider_ref.split("/")[-1]]

        model_ref = tool_output_schema["properties"]["model_profiles"][
            "additionalProperties"
        ]["$ref"]
        model_schema = schema["$defs"][model_ref.split("/")[-1]]

        assert provider_schema["properties"]["prefers_pagination"]["default"] is False
        assert (
            model_schema["properties"]["expand_to_context_window"]["default"] is False
        )


class TestAnimaGlobalConfigDefaults:
    """Tests for defaults section behavior in AnimaGlobalConfig."""

    def test_defaults_accepts_context_window_tokens(self) -> None:
        """AnimaGlobalConfig accepts defaults.context_window_tokens."""
        cfg = AnimaGlobalConfig(
            defaults=GlobalDefaultsConfig(
                model="openai:gpt-4o-mini",
                context_window_tokens=120000,
            )
        )

        assert cfg.defaults.model == "openai:gpt-4o-mini"
        assert cfg.defaults.context_window_tokens == 120000


class TestGlobalDefaultsConfig:
    """Tests for GlobalDefaultsConfig model."""

    def test_default_values(self) -> None:
        """GlobalDefaultsConfig has expected defaults."""
        cfg = GlobalDefaultsConfig()

        assert cfg.model is None
        assert cfg.llm_timeout is None
        assert cfg.context_window_tokens is None

    def test_context_window_tokens_accepts_valid_values(self) -> None:
        """context_window_tokens accepts None and positive integers."""
        cfg = GlobalDefaultsConfig(context_window_tokens=120000)
        assert cfg.context_window_tokens == 120000

        cfg = GlobalDefaultsConfig(context_window_tokens=None)
        assert cfg.context_window_tokens is None


class TestAnimaGlobalConfigToolOutput:
    """Tests for ToolOutput-related config schema fields."""

    def test_accepts_tool_output_config(self) -> None:
        """ToolOutput config accepts complete tool-output governance shape."""
        cfg = AnimaGlobalConfig(
            **cast(
                Any,
                {
                    "tool_output": {
                        "profile": "default",
                        "per_call_cap_bytes": 120000,
                        "per_turn_cap_bytes": 240000,
                        "history_strategy": "summary_placeholder",
                        "provider_profiles": {
                            "default": {
                                "hard_cap_per_call_bytes": 160000,
                                "hard_cap_per_turn_bytes": 320000,
                                "prefers_pagination": True,
                            }
                        },
                        "tool_defaults": {
                            "directory_tree": {
                                "strategy": "paginate_then_excerpt",
                                "soft_cap_bytes": 80000,
                                "max_items": 200,
                                "max_depth": 4,
                                "preserve_structured_excerpt": True,
                            }
                        },
                    }
                },
            )
        )

        payload = cfg.model_dump()
        assert payload["tool_output"]["profile"] == "default"
        assert payload["tool_output"]["per_call_cap_bytes"] == 120000
        assert payload["tool_output"]["per_turn_cap_bytes"] == 240000
        assert (
            payload["tool_output"]["provider_profiles"]["default"][
                "hard_cap_per_call_bytes"
            ]
            == 160000
        )

    def test_rejects_invalid_tool_output_caps(self) -> None:
        """ToolOutput caps reject non-positive values."""
        with pytest.raises(ValidationError):
            AnimaGlobalConfig(
                **cast(
                    Any,
                    {
                        "tool_output": {
                            "per_call_cap_bytes": 0,
                            "per_turn_cap_bytes": -1,
                        }
                    },
                )
            )

        with pytest.raises(ValidationError):
            AnimaGlobalConfig(
                **cast(
                    Any,
                    {
                        "tool_output": {
                            "provider_profiles": {
                                "default": {
                                    "hard_cap_per_call_bytes": 0,
                                    "hard_cap_per_turn_bytes": -1,
                                }
                            }
                        }
                    },
                )
            )

    def test_defaults_without_tool_output_section(self) -> None:
        """Defaults remain valid when tool_output section is absent."""
        cfg = AnimaGlobalConfig()
        payload = cfg.model_dump()
        assert "tool_output" in payload

    @pytest.mark.parametrize(
        "value,valid",
        [
            (0, False),
            (-1, False),
            (1, True),
            (2048, True),
        ],
    )
    def test_context_window_tokens_validation(self, value: int, valid: bool) -> None:
        """context_window_tokens must be >= 1 if set."""
        if valid:
            cfg = GlobalDefaultsConfig(context_window_tokens=value)
            assert cfg.context_window_tokens == value
        else:
            with pytest.raises(ValidationError):
                GlobalDefaultsConfig(context_window_tokens=value)
