"""F4 tests — Model Intelligence, free-only routing and fail-closed egress.

Every test here exists to fail when the control it names stops working.  Tests
that pass regardless of the implementation were removed on purpose: a green
suite that cannot go red proves nothing.
"""

from __future__ import annotations

import asyncio
import hashlib
import inspect
import json
import logging
from unittest.mock import Mock

import pytest

from cyberbrain26.control_plane.model_gateway import (
    ModelGateway,
    ModelGatewayConfig,
    ModelNotAvailableError,
)
from cyberbrain26.control_plane.model_gateway.model_egress import ModelEgressGate
from cyberbrain26.model_layer.kilo_config import (
    KiloConfigBuilder,
    NoFreeModelAvailable,
)
from cyberbrain26.model_layer.model_registry import (
    ModelFreeStatus,
    ModelRecord,
    ModelRegistry,
    ModelStatus,
)
from cyberbrain26.model_layer.provider_registry import (
    ProviderRecord,
    ProviderRegistry,
    ProviderStatus,
)
from cyberbrain26.model_layer.role_assignment import (
    ModelRef,
    NoAvailableModel,
    RoleAssignmentStore,
)
from cyberbrain26.model_layer.router import ModelRouter, ModelRouterError
from cyberbrain26.security_plane.egress import EgressGate, EgressPolicy, EgressRequest
from cyberbrain26.security_plane.permission import SecurityError

# ======================================================================
# Fixtures / helpers
# ======================================================================


def _registry(*records: ModelRecord) -> tuple[ModelRegistry, ModelRouter]:
    """Build a registry + router pair from ``records``."""
    registry = ModelRegistry()
    providers = ProviderRegistry()
    for record in records:
        registry.register(record)
        if providers.get(record.provider) is None:
            providers.register(
                ProviderRecord(
                    provider=record.provider,
                    status=ProviderStatus.ACTIVE,
                    api_available=True,
                )
            )
    return registry, ModelRouter(model_registry=registry, provider_registry=providers)


def _free(model_id: str = "free-a", provider: str = "ollama") -> ModelRecord:
    return ModelRecord(
        model_id=model_id,
        provider=provider,
        status=ModelStatus.ACTIVE,
        free_status=ModelFreeStatus.FREE,
    )


def _paid(model_id: str = "paid-b", provider: str = "ollama") -> ModelRecord:
    return ModelRecord(
        model_id=model_id,
        provider=provider,
        status=ModelStatus.ACTIVE,
        free_status=ModelFreeStatus.PAID,
    )


# ======================================================================
# Role assignment
# ======================================================================


class TestRoleAssignment:
    def test_two_roles_get_distinct_models(self) -> None:
        store = RoleAssignmentStore()
        store.assign("role-x", ModelRef("p", "model-a", is_free=True))
        store.assign("role-y", ModelRef("p", "model-b", is_free=True))

        assert store.distinct_models() == {"model-a", "model-b"}

    def test_select_for_role_uses_main_when_available(self) -> None:
        store = RoleAssignmentStore()
        main = ModelRef("p", "main-model", is_free=True)
        store.assign("role-x", main, [ModelRef("p", "backup", is_free=True)])

        assert store.select_for_role("role-x", main_available=True) is main

    def test_select_for_role_falls_back_to_free_backup(self) -> None:
        store = RoleAssignmentStore()
        backup = ModelRef("p", "free-backup", verified=True, is_free=True)
        store.assign("role-x", ModelRef("p", "main-model", is_free=True), [backup])

        assert store.select_for_role("role-x", main_available=False) is backup

    def test_select_for_role_refuses_paid_backup(self) -> None:
        store = RoleAssignmentStore()
        store.assign(
            "role-x",
            ModelRef("p", "main-model", is_free=True),
            [ModelRef("p", "paid-backup", verified=True, is_free=False)],
        )

        with pytest.raises(NoAvailableModel):
            store.select_for_role("role-x", main_available=False)

    def test_select_for_role_refuses_unverified_backup(self) -> None:
        store = RoleAssignmentStore()
        store.assign(
            "role-x",
            ModelRef("p", "main-model", is_free=True),
            [ModelRef("p", "unverified", verified=False, is_free=True)],
        )

        with pytest.raises(NoAvailableModel):
            store.select_for_role("role-x", main_available=False)

    def test_select_for_role_unknown_role_raises(self) -> None:
        with pytest.raises(NoAvailableModel):
            RoleAssignmentStore().select_for_role("nope")

    def test_record_result_appends(self) -> None:
        store = RoleAssignmentStore()
        store.assign("role-x", ModelRef("p", "main-model", is_free=True))
        store.record_result("role-x", "chat", "main-model", True)

        assignment = store.get("role-x")
        assert assignment is not None
        assert assignment.results == [
            {"task_kind": "chat", "model_id": "main-model", "success": True}
        ]

    def test_refusal_does_not_return_the_paid_backup(self) -> None:
        """A rejected backup must raise, never silently return the main model."""
        store = RoleAssignmentStore()
        main = ModelRef("p", "main-model", is_free=True)
        store.assign("role-x", main, [ModelRef("p", "paid", is_free=False)])

        with pytest.raises(NoAvailableModel):
            store.select_for_role("role-x", main_available=False)


# ======================================================================
# Free-only routing
# ======================================================================


class TestRouterFreeOnly:
    def test_route_free_only_returns_free_model(self) -> None:
        _, router = _registry(_free(), _paid())

        assert router.route(free_only=True).model_id == "free-a"

    def test_route_free_only_never_returns_paid(self) -> None:
        _, router = _registry(_paid())

        with pytest.raises(ModelRouterError):
            router.route(free_only=True)

    def test_route_free_only_ignores_unknown_free_status(self) -> None:
        unknown = ModelRecord(
            model_id="mystery",
            provider="ollama",
            status=ModelStatus.ACTIVE,
            free_status=ModelFreeStatus.UNKNOWN,
        )
        _, router = _registry(unknown)

        with pytest.raises(ModelRouterError):
            router.route(free_only=True)

    def test_route_without_free_only_may_use_paid(self) -> None:
        _, router = _registry(_free(), _paid())

        assert router.route().model_id in {"free-a", "paid-b"}

    def test_route_specific_paid_model_with_free_only_is_refused(self) -> None:
        _, router = _registry(_free(), _paid())

        try:
            result = router.route(model_id="paid-b", free_only=True)
        except ModelRouterError:
            return
        assert result.model_id != "paid-b"

    def test_free_only_substitution_is_disclosed_in_reason(self) -> None:
        """Substituce musí být vyznačena, ne schována v generickém `reason`.

        Proč tento test existuje vedle
        `test_route_specific_paid_model_with_free_only_is_refused`: ten kontroluje
        jen `model_id != "paid-b"`, takže projdou i při tichém přesměrování —
        volající by dostal jiný model a `reason` by zněl `cost optimized`,
        tedy jako by dostal to, co žádal. Tento test padá, když se substituce
        přestane hlásit.
        """
        _, router = _registry(_free(), _paid())

        result = router.route(model_id="paid-b", free_only=True)

        assert result.model_id == "free-a"
        assert "paid-b" in result.reason, (
            f"reason neuvádí, který model byl žádán: {result.reason!r}")
        assert "substitut" in result.reason.lower(), (
            f"reason neuvádí, že došlo k substituci: {result.reason!r}")
        assert "free-a" in result.reason, (
            f"reason neuvádí, čím byl nahrazen: {result.reason!r}")

    def test_no_substitution_reason_when_requested_model_is_eligible(self) -> None:
        """`reason` nesmí tvrdit substituci, když žádná nenastala."""
        _, router = _registry(_free(), _paid())

        result = router.route(model_id="free-a", free_only=True)

        assert result.model_id == "free-a"
        assert "substitut" not in result.reason.lower(), (
            f"reason tvrdí substituci bez důvodu: {result.reason!r}")

    def test_no_substitution_reason_without_free_only(self) -> None:
        """Bez `free_only` je žádaný model použit, takže není co hlásit."""
        _, router = _registry(_free(), _paid())

        result = router.route(model_id="paid-b", free_only=False)

        assert result.model_id == "paid-b"
        assert "substitut" not in result.reason.lower(), (
            f"reason tvrdí substituci bez důvodu: {result.reason!r}")

    def test_fallback_respects_free_only(self) -> None:
        _, router = _registry(_free(), _paid())

        routes = router.route_with_fallback(model_id="__missing__", free_only=True)

        assert routes
        assert all(r.model_id == "free-a" for r in routes)

    def test_fallback_raises_when_no_free_model_available(self) -> None:
        _, router = _registry(_paid())

        with pytest.raises(ModelRouterError):
            router.route_with_fallback(model_id="__missing__", free_only=True)

    def test_free_only_defaults_to_false(self) -> None:
        params = inspect.signature(ModelRouter.route).parameters
        fallback_params = inspect.signature(ModelRouter.route_with_fallback).parameters

        assert params["free_only"].default is False
        assert fallback_params["free_only"].default is False

    def test_free_eligible_rejects_paid_and_unknown(self) -> None:
        router = ModelRouter(model_registry=ModelRegistry(), provider_registry=ProviderRegistry())

        assert router._is_free_eligible(_paid()) is False
        assert router._is_free_eligible(_free()) is True
        assert (
            router._is_free_eligible(
                ModelRecord(
                    model_id="mystery",
                    provider="ollama",
                    free_status=ModelFreeStatus.UNKNOWN,
                )
            )
            is False
        )


# ======================================================================
# Model Gateway wiring — the gateway is the live path, so it needs tests
# ======================================================================


class TestGatewayFreeOnly:
    def test_gateway_defaults_to_free_only(self) -> None:
        assert ModelGatewayConfig().free_only is True

    def test_gateway_route_never_returns_paid_by_default(self) -> None:
        gateway = ModelGateway()
        gateway.register_model(
            ModelRecord(
                model_id="paid-only",
                provider="ollama",
                status=ModelStatus.ACTIVE,
                free_status=ModelFreeStatus.PAID,
            )
        )
        for model in list(gateway.model_registry.list_active()):
            if not model.is_free:
                gateway.model_registry.unregister(model.model_id, model.provider)

        assert gateway.route()["model_id"] != "paid-only"

    def test_gateway_route_refuses_paid_when_free_only(self) -> None:
        gateway = ModelGateway()
        for model in list(gateway.model_registry.list_active()):
            gateway.model_registry.unregister(model.model_id, model.provider)
        gateway.register_model(_paid("gateway-paid", "ollama"))

        with pytest.raises(ModelRouterError):
            gateway.route(free_only=True)

    def test_gateway_route_with_fallback_refuses_paid_only_registry(self) -> None:
        gateway = ModelGateway()
        for model in list(gateway.model_registry.list_active()):
            gateway.model_registry.unregister(model.model_id, model.provider)
        gateway.register_model(_paid("gateway-paid", "ollama"))

        with pytest.raises(ModelRouterError):
            gateway.route_with_fallback(model_id="__missing__", free_only=True)

    def test_gateway_route_with_fallback_accepts_free_only(self) -> None:
        gateway = ModelGateway()

        routes = gateway.route_with_fallback(free_only=True)

        assert routes
        for entry in routes:
            record = gateway.model_registry.get(entry["model_id"])
            assert record is None or record.is_free

    def test_candidate_filter_drops_paid(self) -> None:
        _, router = _registry(_free(), _paid())

        free = router._get_candidates(None, None, True)
        everything = router._get_candidates(None, None, False)

        assert [m.model_id for m in free] == ["free-a"]
        assert len(everything) == 2

    def test_fallback_candidate_filter_drops_paid(self) -> None:
        _, router = _registry(_free(), _paid())

        free = router._get_fallback_candidates(None, True)
        everything = router._get_fallback_candidates(None, False)

        assert [m.model_id for m in free] == ["free-a"]
        assert len(everything) == 2


# ======================================================================
# Kilo provider configuration
# ======================================================================


class TestKiloConfigBuilder:
    def test_build_whitelist_contains_only_free_models(self) -> None:
        registry, _ = _registry(_free(), _paid())
        builder = KiloConfigBuilder(registry)

        config = builder.build_kilo_provider_config()

        assert config["provider"]["cb26"]["whitelist"] == ["free-a"]

    def test_build_raises_without_free_model(self) -> None:
        registry, _ = _registry(_paid())

        with pytest.raises(NoFreeModelAvailable):
            KiloConfigBuilder(registry).build_kilo_provider_config()

    def test_validate_flags_paid_model(self) -> None:
        registry, _ = _registry(_free(), _paid())
        builder = KiloConfigBuilder(registry)
        config = {
            "provider": {"cb26": {"models": {"paid-b": {}}, "whitelist": ["paid-b"]}}
        }

        problems = builder.validate_kilo_provider_config(config)

        assert problems
        assert any("paid-b" in p for p in problems)

    def test_validate_flags_unknown_model(self) -> None:
        registry, _ = _registry(_free())
        config = {"provider": {"cb26": {"models": {"ghost": {}}, "whitelist": []}}}

        problems = KiloConfigBuilder(registry).validate_kilo_provider_config(config)

        assert any("ghost" in p for p in problems)

    def test_validate_passes_clean_free_config(self) -> None:
        registry, _ = _registry(_free(), _paid())
        builder = KiloConfigBuilder(registry)
        config = builder.build_kilo_provider_config()

        assert builder.validate_kilo_provider_config(config) == []

    def test_validate_requires_provider_block(self) -> None:
        registry, _ = _registry(_free())

        problems = KiloConfigBuilder(registry).validate_kilo_provider_config({})

        assert problems

    def test_apply_writes_file_atomically(self, tmp_path) -> None:
        registry, _ = _registry(_free(), _paid())
        builder = KiloConfigBuilder(registry)
        config = builder.build_kilo_provider_config()
        target = tmp_path / "kilo.json"

        result = builder.apply_kilo_config(str(target), config)

        assert result["applied"] is True
        assert json.loads(target.read_text(encoding="utf-8")) == config
        assert not (tmp_path / "kilo.json.tmp").exists()

    def test_apply_keeps_original_when_write_fails(self, tmp_path) -> None:
        """Atomicity: a serialisation failure must not truncate the live file."""
        registry, _ = _registry(_free())
        builder = KiloConfigBuilder(registry)
        target = tmp_path / "kilo.json"
        target.write_text('{"version": 1}', encoding="utf-8")
        unserialisable = {"provider": {"cb26": {"models": {}, "whitelist": []}},
                          "bad": {1, 2, 3}}

        with pytest.raises(TypeError):
            builder.apply_kilo_config(str(target), unserialisable)

        assert target.read_text(encoding="utf-8") == '{"version": 1}'

    def test_apply_refuses_invalid_config(self, tmp_path) -> None:
        registry, _ = _registry(_free(), _paid())
        builder = KiloConfigBuilder(registry)
        target = tmp_path / "kilo.json"
        target.write_text('{"untouched": true}', encoding="utf-8")
        bad = {"provider": {"cb26": {"models": {"paid-b": {}}, "whitelist": ["paid-b"]}}}

        result = builder.apply_kilo_config(str(target), bad)

        assert result["applied"] is False
        assert result["problems"]
        assert target.read_text(encoding="utf-8") == '{"untouched": true}'

    def test_backup_returns_none_for_missing_file(self, tmp_path) -> None:
        registry, _ = _registry(_free())

        assert KiloConfigBuilder(registry).backup_kilo_config(
            str(tmp_path / "absent.json")
        ) is None

    def test_backup_then_restore_roundtrip(self, tmp_path) -> None:
        registry, _ = _registry(_free())
        builder = KiloConfigBuilder(registry)
        target = tmp_path / "kilo.json"
        target.write_text('{"version": 1}', encoding="utf-8")

        backup = builder.backup_kilo_config(str(target))
        assert backup is not None

        target.write_text('{"version": 2}', encoding="utf-8")
        assert builder.restore_kilo_config(str(target), backup) is True
        assert target.read_text(encoding="utf-8") == '{"version": 1}'

    def test_restore_refuses_missing_backup(self, tmp_path) -> None:
        registry, _ = _registry(_free())
        target = tmp_path / "kilo.json"
        target.write_text('{"version": 1}', encoding="utf-8")

        assert (
            KiloConfigBuilder(registry).restore_kilo_config(
                str(target), str(tmp_path / "nope.bak")
            )
            is False
        )
        assert target.read_text(encoding="utf-8") == '{"version": 1}'


# ======================================================================
# Fail-closed model egress
# ======================================================================


class TestModelEgressGate:
    def test_missing_classifications_raises(self) -> None:
        gate = ModelEgressGate(egress_gate=Mock())

        with pytest.raises(SecurityError):
            gate.authorize_model_call(
                agent_id="a",
                provider_id="p",
                model_id="m",
                payload_sha256="0" * 64,
                classifications=frozenset(),
            )

    def test_missing_egress_gate_is_fail_closed(self) -> None:
        with pytest.raises(SecurityError) as excinfo:
            ModelEgressGate().authorize_model_call(
                agent_id="a",
                provider_id="p",
                model_id="m",
                payload_sha256="0" * 64,
                classifications=frozenset({"public"}),
            )

        assert "fail open" in str(excinfo.value)

    def test_authorize_calls_gate(self) -> None:
        """Proves the gate is really consulted, not stubbed out."""
        spy = Mock()
        gate = ModelEgressGate(egress_gate=spy)

        gate.authorize_model_call(
            agent_id="agent-1",
            provider_id="ollama",
            model_id="free-a",
            payload_sha256="a" * 64,
            classifications=frozenset({"public"}),
        )

        spy.authorize.assert_called_once()
        request = spy.authorize.call_args.args[0]
        assert isinstance(request, EgressRequest)
        assert request.agent_id == "agent-1"
        assert request.provider_id == "ollama"
        assert request.payload_sha256 == "a" * 64

    def test_authorize_propagates_gate_denial(self) -> None:
        spy = Mock()
        spy.authorize.side_effect = SecurityError("denied by policy")
        gate = ModelEgressGate(egress_gate=spy)

        with pytest.raises(SecurityError) as excinfo:
            gate.authorize_model_call(
                agent_id="a",
                provider_id="p",
                model_id="m",
                payload_sha256="0" * 64,
                classifications=frozenset({"public"}),
            )

        assert "denied by policy" in str(excinfo.value)

    def test_guarded_call_rejects_non_bytes_payload(self) -> None:
        gate = ModelEgressGate(egress_gate=Mock())

        async def never_called() -> str:  # pragma: no cover - must not run
            raise AssertionError("coro_factory must not be reached")

        with pytest.raises(TypeError):
            asyncio.run(
                gate.guarded_model_call(
                    never_called,
                    agent_id="a",
                    provider_id="p",
                    model_id="m",
                    payload=123,
                    classifications=frozenset({"public"}),
                )
            )

    def test_guarded_call_refuses_without_gate(self) -> None:
        async def never_called() -> str:  # pragma: no cover - must not run
            raise AssertionError("coro_factory must not be reached")

        with pytest.raises(SecurityError):
            asyncio.run(
                ModelEgressGate().guarded_model_call(
                    never_called,
                    agent_id="a",
                    provider_id="p",
                    model_id="m",
                    payload=b"hello",
                    classifications=frozenset({"public"}),
                )
            )

    def test_guarded_call_computes_digest_and_executes(self) -> None:
        spy = Mock()
        gate = ModelEgressGate(egress_gate=spy)
        seen: dict[str, str] = {}

        async def fake() -> str:
            seen["digest"] = spy.authorize.call_args.args[0].payload_sha256
            return "ok"

        result = asyncio.run(
            gate.guarded_model_call(
                fake,
                agent_id="a",
                provider_id="p",
                model_id="m",
                payload="hello",
                classifications=frozenset({"public"}),
            )
        )

        assert result == "ok"
        assert seen["digest"] == hashlib.sha256(b"hello").hexdigest()

    def test_real_egress_gate_denies_unknown_provider(self) -> None:
        """Integration: the wrapper must reach the real gate and be refused."""
        audit = Mock()
        real_gate = EgressGate(EgressPolicy(), audit_log=audit, base_decision=True)
        wrapper = ModelEgressGate(egress_gate=real_gate)

        with pytest.raises(SecurityError) as excinfo:
            wrapper.authorize_model_call(
                agent_id="a",
                provider_id="not-in-any-allowlist",
                model_id="m",
                payload_sha256="b" * 64,
                classifications=frozenset({"public"}),
            )

        assert "egress denied" in str(excinfo.value)


# ======================================================================
# Sensitive data must not leak
# ======================================================================


class TestGatewayEgressAuthorization:
    """The gateway is the live path; without a gate the check is not optional."""

    def _gateway(self, gate: object) -> ModelGateway:
        return ModelGateway(egress_gate=gate, agent_id="agent-under-test")

    def test_chat_authorizes_before_reaching_a_provider(self) -> None:
        spy = Mock()
        gateway = self._gateway(spy)
        executed: list[str] = []

        gateway._execute_chat = (  # type: ignore[method-assign]
            lambda *args, **kwargs: executed.append("called") or "response"
        )

        result = gateway.chat(
            "any-model",
            [{"role": "user", "content": "hello"}],
            classifications=frozenset({"public"}),
        )

        assert result == "response"
        assert executed == ["called"]
        spy.authorize.assert_called_once()
        request = spy.authorize.call_args.args[0]
        assert request.agent_id == "agent-under-test"

    def test_chat_digest_matches_the_message_payload(self) -> None:
        spy = Mock()
        gateway = self._gateway(spy)
        messages = [{"role": "user", "content": "hello"}]

        gateway._execute_chat = lambda *a, **k: "response"  # type: ignore[method-assign]
        gateway.chat("any-model", messages, classifications=frozenset({"public"}))

        expected = hashlib.sha256(
            json.dumps(messages, ensure_ascii=False, sort_keys=True).encode("utf-8")
        ).hexdigest()
        assert spy.authorize.call_args.args[0].payload_sha256 == expected

    def test_chat_refuses_missing_classification(self) -> None:
        gateway = self._gateway(Mock())
        gateway._execute_chat = Mock()  # type: ignore[method-assign]

        with pytest.raises(SecurityError) as excinfo:
            gateway.chat("any-model", [{"role": "user", "content": "hi"}])

        assert "assume PUBLIC" in str(excinfo.value)
        gateway._execute_chat.assert_not_called()

    def test_chat_does_not_leak_message_content(self) -> None:
        spy = Mock()
        gateway = self._gateway(spy)
        gateway._execute_chat = lambda *a, **k: "response"  # type: ignore[method-assign]
        secret = "card=4111111111111111"

        gateway.chat(
            "any-model",
            [{"role": "user", "content": secret}],
            classifications=frozenset({"personal"}),
        )

        assert secret not in repr(spy.authorize.call_args.args[0])

    def test_no_gate_means_no_check_and_is_explicit(self) -> None:
        gateway = ModelGateway()
        gateway._execute_chat = Mock(return_value="response")  # type: ignore[method-assign]

        assert gateway.chat("any-model", [{"role": "user", "content": "hi"}]) == "response"

    def test_fallback_provider_is_authorized_separately(self) -> None:
        spy = Mock()
        gateway = self._gateway(spy)
        calls: list[str] = []
        gateway.register_model(_free("fallback-model", "ollama"))

        def flaky(*args: object, **kwargs: object) -> str:
            calls.append(str(args[0]))
            if len(calls) == 1:
                raise RuntimeError("primary down")
            return "response"

        gateway._execute_chat = flaky  # type: ignore[method-assign]
        gateway.config.fallback_providers = ["ollama"]

        gateway.chat(
            "fallback-model",
            [{"role": "user", "content": "hi"}],
            classifications=frozenset({"public"}),
        )

        providers = [call.args[0].provider_id for call in spy.authorize.call_args_list]
        assert len(providers) >= 2
        assert "ollama" in providers

    def test_fallback_refuses_paid_model(self) -> None:
        gateway = self._gateway(Mock())
        calls: list[str] = []

        def always_fails(*args: object, **kwargs: object) -> str:
            calls.append(str(args[0]))
            raise RuntimeError("primary down")

        gateway._execute_chat = always_fails  # type: ignore[method-assign]
        gateway.register_model(_paid("paid-model", "ollama"))
        gateway.config.fallback_providers = ["ollama"]

        with pytest.raises(ModelNotAvailableError):
            gateway.chat(
                "paid-model",
                [{"role": "user", "content": "hi"}],
                classifications=frozenset({"public"}),
            )

        assert len(calls) == 1, "the paid fallback must never be reached"

    def test_fallback_refuses_unknown_model(self) -> None:
        gateway = self._gateway(Mock())
        calls: list[str] = []

        def always_fails(*args: object, **kwargs: object) -> str:
            calls.append(str(args[0]))
            raise RuntimeError("primary down")

        gateway._execute_chat = always_fails  # type: ignore[method-assign]
        gateway.config.fallback_providers = ["ollama"]

        with pytest.raises(ModelNotAvailableError):
            gateway.chat(
                "not-in-registry",
                [{"role": "user", "content": "hi"}],
                classifications=frozenset({"public"}),
            )

        assert len(calls) == 1

    def test_model_is_free_fails_closed(self) -> None:
        gateway = ModelGateway()

        assert gateway._model_is_free("definitely-not-registered") is False


class TestSensitiveDataLeakage:
    SECRET_CARD = "4111111111111111"
    SECRET_EMAIL = "jan@example.invalid"

    def test_raw_payload_never_logged(self, caplog: pytest.LogCaptureFixture) -> None:
        spy = Mock()
        gate = ModelEgressGate(egress_gate=spy)
        payload = f"card={self.SECRET_CARD} email={self.SECRET_EMAIL}"

        async def fake() -> str:
            return "ok"

        with caplog.at_level(logging.DEBUG):
            asyncio.run(
                gate.guarded_model_call(
                    fake,
                    agent_id="agent-1",
                    provider_id="ollama",
                    model_id="free-a",
                    payload=payload,
                    classifications=frozenset({"public"}),
                )
            )

        assert caplog.records, "expected the call to be logged at some level"
        for record in caplog.records:
            rendered = record.getMessage()
            assert self.SECRET_CARD not in rendered
            assert self.SECRET_EMAIL not in rendered

    def test_egress_request_carries_digest_not_content(self) -> None:
        spy = Mock()
        gate = ModelEgressGate(egress_gate=spy)
        payload = f"card={self.SECRET_CARD}"

        async def fake() -> str:
            return "ok"

        asyncio.run(
            gate.guarded_model_call(
                fake,
                agent_id="agent-1",
                provider_id="ollama",
                model_id="free-a",
                payload=payload,
                classifications=frozenset({"public"}),
            )
        )

        request = spy.authorize.call_args.args[0]
        assert self.SECRET_CARD not in repr(request)
        assert request.payload_sha256 == hashlib.sha256(payload.encode()).hexdigest()

    def test_authorize_logs_digest_only(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        spy = Mock()
        gate = ModelEgressGate(egress_gate=spy)

        with caplog.at_level(logging.DEBUG):
            gate.authorize_model_call(
                agent_id="agent-1",
                provider_id="ollama",
                model_id="free-a",
                payload_sha256="c" * 64,
                classifications=frozenset({"public"}),
            )

        assert caplog.records, "authorize must leave an audit trail in the log"
        for record in caplog.records:
            assert self.SECRET_CARD not in record.getMessage()