"""Tests for ContextService and model-visible runtime context projection.

Spec refs:
- docs/proposals/execution-architecture-refactor.md#execution-contract-specific-design-rules/model-visible-runtime-context
- docs/proposals/execution-architecture-refactor.md#repository-target-architecture/app
- docs/proposals/execution-architecture-refactor.md#invariants

Test-only constraint:
- Verify context service projects model-visible facts from contract only.
- Verify no independent derivation of filesystem/project-root hints.
"""

from __future__ import annotations

import pytest

from anima.app.context_service import (
    ContextService,
    DefaultContextService,
    ModelContextAdapterSurface,
    ModelContextProjection,
)
from anima.core.contracts import (
    CONTRACT_SCHEMA_VERSION,
    ContractIdentity,
    ContractResolverProvenance,
    ContractSourceHashes,
    PersonaProvenance,
    PersonaSpawnability,
    RuntimeModelContext,
    RuntimeOutputPolicy,
    RuntimeToolAccess,
)
from anima.core.execution import (
    ResolvedExecutionContract,
    ResolvedLifecyclePolicy,
    ResolvedRuntime,
)
from anima.core.schemas.enums import CompletionPolicy, LifecycleType, SideEffectPolicy


def make_contract_with_context(
    instance_id: str = "inst_test",
    work_dir: str | None = None,
    project_root: str | None = None,
    filesystem_scope: str | None = None,
    effective_tool_count: int = 0,
    effective_model: str = "gpt-4o",
    provider_profile: str = "openai-compatible",
    per_call_chars: int | None = None,
    per_turn_chars: int | None = None,
) -> ResolvedExecutionContract:
    """Create a resolved contract fixture with specified model context values."""
    return ResolvedExecutionContract(
        identity=ContractIdentity(
            instance_id=instance_id,
            contract_version=CONTRACT_SCHEMA_VERSION,
            resolver=ContractResolverProvenance(
                resolver_version="resolver/0.1.0",
                source_hashes=ContractSourceHashes(
                    cli_digest="sha256:test",
                ),
            ),
        ),
        runtime=ResolvedRuntime(
            effective_model=effective_model,
            work_dir=work_dir,
            project_root=project_root,
            filesystem_scope=filesystem_scope,
        ),
        tool_access=RuntimeToolAccess(
            declared_persona_tools=(),
            requested_cli_tools=(),
            requested_mcp_tools=(),
            effective_tools=(),
            effective_presets=(),
            effective_side_effect_policy=SideEffectPolicy.allow,
        ),
        persona_provenance=PersonaProvenance(
            source="file",
            materialization_status="not_required",
            inline_behavior="reject_spawn",
            child_inheritance_policy="require_explicit_child_persona",
            spawnability=PersonaSpawnability(can_spawn=True),
        ),
        lifecycle_policy=ResolvedLifecyclePolicy(
            lifecycle=LifecycleType.ephemeral,
            completion_policy=CompletionPolicy.explicit,
        ),
        output_policy=RuntimeOutputPolicy(
            provider_profile=provider_profile,
            per_call_tool_output_chars=per_call_chars,
            per_turn_tool_output_chars=per_turn_chars,
            truncation_strategy="paginate_then_truncate",
        ),
        model_context=RuntimeModelContext(
            values={
                "instance_id": instance_id,
                "effective_model": effective_model,
                "lifecycle": "ephemeral",
                "completion_policy": "explicit",
                "spawnable": True,
                "work_dir": work_dir,
                "project_root": project_root,
                "filesystem_scope": filesystem_scope,
                "effective_tool_count": effective_tool_count,
            }
        ),
    )


class TestDefaultContextServiceProjection:
    """Tests proving DefaultContextService projects from contract values only."""

    @pytest.fixture
    def service(self) -> ContextService:
        """Provide a DefaultContextService instance for tests."""
        return DefaultContextService()

    def test_projects_instance_id_from_contract(self, service: ContextService) -> None:
        """instance_id must project from contract.model_context.values."""
        contract = make_contract_with_context(instance_id="inst_abc123")

        projection = service.project_model_context(contract)

        assert projection.runtime_context.values.get("instance_id") == "inst_abc123"

    def test_projects_work_dir_from_contract(self, service: ContextService) -> None:
        """work_dir must project from contract values only."""
        contract = make_contract_with_context(work_dir="/workspace/project")

        projection = service.project_model_context(contract)

        assert projection.runtime_context.values.get("work_dir") == "/workspace/project"

    def test_projects_project_root_from_contract(self, service: ContextService) -> None:
        """project_root must project from contract values only."""
        contract = make_contract_with_context(project_root="/projects/myapp")

        projection = service.project_model_context(contract)

        assert (
            projection.runtime_context.values.get("project_root") == "/projects/myapp"
        )

    def test_projects_filesystem_scope_from_contract(
        self, service: ContextService
    ) -> None:
        """filesystem_scope must project from contract values only."""
        contract = make_contract_with_context(filesystem_scope="project-root")

        projection = service.project_model_context(contract)

        assert (
            projection.runtime_context.values.get("filesystem_scope") == "project-root"
        )

    def test_projects_effective_tool_count_from_contract(
        self, service: ContextService
    ) -> None:
        """effective_tool_count must project from contract values only."""
        contract = make_contract_with_context(effective_tool_count=5)

        projection = service.project_model_context(contract)

        assert projection.runtime_context.values.get("effective_tool_count") == 5

    def test_projects_none_values_as_none(self, service: ContextService) -> None:
        """None values must project as None, not missing or transformed."""
        contract = make_contract_with_context(
            work_dir=None,
            project_root=None,
            filesystem_scope=None,
        )

        projection = service.project_model_context(contract)

        # None values should be None, not missing
        assert projection.runtime_context.values.get("work_dir") is None
        assert projection.runtime_context.values.get("project_root") is None
        assert projection.runtime_context.values.get("filesystem_scope") is None

    def test_projection_contains_output_policy(self, service: ContextService) -> None:
        """Projection must include output_policy from contract."""
        contract = make_contract_with_context(
            provider_profile="anthropic",
            per_call_chars=10000,
            per_turn_chars=50000,
        )

        projection = service.project_model_context(contract)

        assert projection.output_policy.provider_profile == "anthropic"
        assert projection.output_policy.per_call_tool_output_chars == 10000
        assert projection.output_policy.per_turn_tool_output_chars == 50000

    def test_output_policy_truncation_strategy(self, service: ContextService) -> None:
        """Projection must expose truncation_strategy."""
        contract = make_contract_with_context()

        projection = service.project_model_context(contract)

        assert projection.output_policy.truncation_strategy == "paginate_then_truncate"

    def test_adapter_surface_default(self, service: ContextService) -> None:
        """Default adapter_surface must be runtime_model_instructions."""
        contract = make_contract_with_context()

        projection = service.project_model_context(contract)

        assert projection.adapter_surface == "runtime_model_instructions"

    def test_adapter_surface_explicit(self, service: ContextService) -> None:
        """Explicit adapter_surface must be preserved in projection."""
        contract = make_contract_with_context()
        adapter_surface: ModelContextAdapterSurface = "runtime_model_instructions"

        projection = service.project_model_context(
            contract, adapter_surface=adapter_surface
        )

        assert projection.adapter_surface == "runtime_model_instructions"


class TestContextServiceInvariantNoIndependentDerivation:
    """Tests proving context service never independently derives facts.

    All model-visible facts must come exclusively from the contract.
    No adapter should re-compute filesystem/project-root hints.
    """

    @pytest.fixture
    def service(self) -> ContextService:
        """Provide a DefaultContextService instance for tests."""
        return DefaultContextService()

    def test_work_der_not_recomputed_from_runtime(
        self, service: ContextService
    ) -> None:
        """work_dir must never be computed independently from ResolvedRuntime.

        The contract resolver already sets work_dir. ContextService must NOT
        re-read from runtime.work_dir.
        """
        # Contract with work_dir="/workspace"
        contract = make_contract_with_context(work_dir="/workspace")

        projection = service.project_model_context(contract)

        # The value comes directly from model_context.values
        # (which was set by the resolver)
        assert projection.runtime_context.values.get("work_dir") == "/workspace"

    def test_project_root_not_recomputed_from_runtime(
        self, service: ContextService
    ) -> None:
        """project_root must never be computed independently from ResolvedRuntime.

        The contract resolver already sets project_root. ContextService must NOT
        re-read from runtime.project_root.
        """
        contract = make_contract_with_context(project_root="/project")

        projection = service.project_model_context(contract)

        assert projection.runtime_context.values.get("project_root") == "/project"

    def test_no_transformation_of_values(self, service: ContextService) -> None:
        """ContextService must NOT transform or augment contract values.

        The values must pass through unchanged - no prefix/suffix additions,
        no path normalization, no enrichment.
        """
        contract = make_contract_with_context(
            work_dir="/original/path",
            filesystem_scope="original-scope",
            effective_tool_count=3,
        )

        projection = service.project_model_context(contract)

        # Values must match exactly - no transformation
        assert projection.runtime_context.values.get("work_dir") == "/original/path"
        assert (
            projection.runtime_context.values.get("filesystem_scope")
            == "original-scope"
        )
        assert projection.runtime_context.values.get("effective_tool_count") == 3


class TestContextServiceModelContextViewProtocol:
    """Tests proving projection satisfies RuntimeContextFactsView protocol."""

    @pytest.fixture
    def service(self) -> ContextService:
        """Provide a DefaultContextService instance for tests."""
        return DefaultContextService()

    def test_runtime_context_has_values_attribute(
        self, service: ContextService
    ) -> None:
        """runtime_context must have 'values' attribute per RuntimeContextFactsView."""
        contract = make_contract_with_context()

        projection = service.project_model_context(contract)

        # Protocol requires .values attribute
        assert hasattr(projection.runtime_context, "values")
        assert isinstance(projection.runtime_context.values, dict)

    def test_values_are_primitives(self, service: ContextService) -> None:
        """All values must be primitive types suitable for prompt injection."""
        contract = make_contract_with_context(
            work_dir="/path",
            effective_tool_count=10,
        )

        projection = service.project_model_context(contract)

        for key, value in projection.runtime_context.values.items():
            assert value is None or isinstance(value, (str, int, float, bool)), (
                f"model_context[{key}] must be primitive, got {type(value)}"
            )


class TestContextServiceOutputPolicyViewProtocol:
    """Tests proving projection satisfies RuntimeOutputPolicyView protocol."""

    @pytest.fixture
    def service(self) -> ContextService:
        """Provide a DefaultContextService instance for tests."""
        return DefaultContextService()

    def test_output_policy_has_provider_profile(self, service: ContextService) -> None:
        """output_policy must have 'provider_profile' attribute."""
        contract = make_contract_with_context(provider_profile="test-provider")

        projection = service.project_model_context(contract)

        assert hasattr(projection.output_policy, "provider_profile")
        assert projection.output_policy.provider_profile == "test-provider"

    def test_output_policy_has_truncation_strategy(
        self, service: ContextService
    ) -> None:
        """output_policy must have 'truncation_strategy' attribute."""
        contract = make_contract_with_context()

        projection = service.project_model_context(contract)

        assert hasattr(projection.output_policy, "truncation_strategy")
        assert projection.output_policy.truncation_strategy == "paginate_then_truncate"

    def test_output_policy_has_caps(self, service: ContextService) -> None:
        """output_policy must have per_call/per_turn caps."""
        contract = make_contract_with_context(
            per_call_chars=10000,
            per_turn_chars=50000,
        )

        projection = service.project_model_context(contract)

        assert hasattr(projection.output_policy, "per_call_tool_output_chars")
        assert hasattr(projection.output_policy, "per_turn_tool_output_chars")
        assert projection.output_policy.per_call_tool_output_chars == 10000
        assert projection.output_policy.per_turn_tool_output_chars == 50000


class TestProjectionImmutability:
    """Tests proving projection does not mutate contract."""

    @pytest.fixture
    def service(self) -> ContextService:
        """Provide a DefaultContextService instance for tests."""
        return DefaultContextService()

    def test_projection_does_not_modify_contract(self, service: ContextService) -> None:
        """Calling project_model_context must not modify the contract."""
        contract = make_contract_with_context(
            work_dir="/workspace",
            effective_tool_count=5,
        )

        # Capture original values
        original_work_dir = contract.model_context.values.get("work_dir")
        original_tool_count = contract.model_context.values.get("effective_tool_count")

        # Project (should not mutate)
        service.project_model_context(contract)

        # Verify unchanged
        assert contract.model_context.values.get("work_dir") == original_work_dir
        assert (
            contract.model_context.values.get("effective_tool_count")
            == original_tool_count
        )
