"""Tests for the Python liteguard SDK evaluation engine and client."""

import asyncio
import json
import threading
import time

import pytest
import liteguard

from liteguard.evaluation import evaluate_guard
from liteguard.types import (
    Guard,
    GuardCheckPerformance,
    GuardExecutionPerformance,
    ClientOptions,
    Options,
    Operator,
    ProtectedContext,
    Rule,
    SignalPerformance,
)


def make_guard(**kwargs) -> Guard:
    defaults = dict(name="test.guard", rules=[], default_value=False, adopted=True, rate_limit_per_minute=0, rate_limit_properties=[])
    defaults.update(kwargs)
    return Guard(**defaults)


def make_rule(**kwargs) -> Rule:
    defaults = dict(property_name="plan", operator=Operator.EQUALS, values=["pro"], result=True, enabled=True)
    defaults.update(kwargs)
    return Rule(**defaults)


# ---------------------------------------------------------------------------
# evaluate_guard
# ---------------------------------------------------------------------------

class TestEvaluateGuard:
    def test_returns_default_when_no_rules(self):
        assert evaluate_guard(make_guard(default_value=False), {}) is False
        assert evaluate_guard(make_guard(default_value=True), {}) is True

    def test_equals_match(self):
        guard = make_guard(rules=[make_rule(operator=Operator.EQUALS, values=["pro"], result=True)])
        assert evaluate_guard(guard, {"plan": "pro"}) is True
        assert evaluate_guard(guard, {"plan": "free"}) is False

    def test_disabled_rule_is_skipped(self):
        guard = make_guard(rules=[make_rule(enabled=False)])
        assert evaluate_guard(guard, {"plan": "pro"}) is False

    def test_not_equals(self):
        guard = make_guard(rules=[make_rule(operator=Operator.NOT_EQUALS, values=["free"], result=True)])
        assert evaluate_guard(guard, {"plan": "pro"}) is True
        assert evaluate_guard(guard, {"plan": "free"}) is False

    def test_in(self):
        guard = make_guard(
            rules=[make_rule(property_name="userId", operator=Operator.IN, values=["alice", "bob"], result=True)]
        )
        assert evaluate_guard(guard, {"userId": "alice"}) is True
        assert evaluate_guard(guard, {"userId": "charlie"}) is False

    def test_not_in(self):
        guard = make_guard(
            rules=[
                make_rule(
                    property_name="userId",
                    operator=Operator.NOT_IN,
                    values=["blocked-1"],
                    result=True,
                )
            ]
        )
        assert evaluate_guard(guard, {"userId": "alice"}) is True
        assert evaluate_guard(guard, {"userId": "blocked-1"}) is False

    def test_regex(self):
        guard = make_guard(
            rules=[
                make_rule(
                    property_name="email",
                    operator=Operator.REGEX,
                    values=[r"^.*@acme\.com$"],
                    result=True,
                )
            ]
        )
        assert evaluate_guard(guard, {"email": "alice@acme.com"}) is True
        assert evaluate_guard(guard, {"email": "alice@other.com"}) is False

    def test_invalid_regex_fails_closed(self):
        guard = make_guard(
            rules=[
                make_rule(
                    property_name="email",
                    operator=Operator.REGEX,
                    values=["[invalid"],
                    result=True,
                )
            ]
        )
        assert evaluate_guard(guard, {"email": "alice@acme.com"}) is False

    def test_rules_without_comparison_values_fail_closed(self):
        assert evaluate_guard(
            make_guard(
                rules=[make_rule(operator=Operator.EQUALS, values=[], result=True)],
                default_value=False,
            ),
            {"plan": "pro"},
        ) is False
        assert evaluate_guard(
            make_guard(
                rules=[
                    make_rule(
                        property_name="userId",
                        operator=Operator.NOT_IN,
                        values=[],
                        result=True,
                    )
                ],
                default_value=False,
            ),
            {"userId": "alice"},
        ) is False
        assert evaluate_guard(
            make_guard(
                rules=[make_rule(property_name="score", operator=Operator.GT, values=[], result=True)],
                default_value=False,
            ),
            {"score": 99},
        ) is False
        assert evaluate_guard(
            make_guard(
                rules=[make_rule(property_name="email", operator=Operator.REGEX, values=[], result=True)],
                default_value=False,
            ),
            {"email": "alice@acme.com"},
        ) is False

    @pytest.mark.parametrize(
        "op,threshold,passing,failing",
        [
            (Operator.GT, 50, 51, 50),
            (Operator.GTE, 50, 50, 49),
            (Operator.LT, 50, 49, 50),
            (Operator.LTE, 50, 50, 51),
        ],
    )
    def test_numeric_operators(self, op, threshold, passing, failing):
        guard = make_guard(
            rules=[make_rule(property_name="score", operator=op, values=[threshold], result=True)]
        )
        assert evaluate_guard(guard, {"score": passing}) is True
        assert evaluate_guard(guard, {"score": failing}) is False

    def test_strict_type_aware_comparisons(self):
        assert evaluate_guard(
            make_guard(rules=[make_rule(operator=Operator.EQUALS, values=["1"], result=True)]),
            {"plan": 1},
        ) is False
        assert evaluate_guard(
            make_guard(
                rules=[make_rule(property_name="score", operator=Operator.GT, values=["50"], result=True)]
            ),
            {"score": 75},
        ) is False
        assert evaluate_guard(
            make_guard(
                rules=[make_rule(property_name="enabled", operator=Operator.EQUALS, values=[True], result=True)]
            ),
            {"enabled": 1},
        ) is False
        assert evaluate_guard(
            make_guard(
                rules=[make_rule(property_name="enabled", operator=Operator.EQUALS, values=[True], result=True)]
            ),
            {"enabled": True},
        ) is True

    def test_first_match_wins(self):
        guard = make_guard(
            rules=[
                make_rule(result=True),
                make_rule(result=False),
            ]
        )
        assert evaluate_guard(guard, {"plan": "pro"}) is True

    def test_missing_property_does_not_match(self):
        guard = make_guard(rules=[make_rule()])
        assert evaluate_guard(guard, {}) is False


# ---------------------------------------------------------------------------
# LiteguardClient
# ---------------------------------------------------------------------------

from liteguard.client import LiteguardClient


def make_client() -> LiteguardClient:
    return LiteguardClient("test-key", ClientOptions(backend_url="http://localhost:9999"))


class TestLiteguardClient:
    def test_shutdown_uses_configured_http_timeout_for_worker_joins(self):
        client = LiteguardClient("test-key", ClientOptions(backend_url="http://localhost:9999", http_timeout_seconds=7))

        class _Thread:
            def __init__(self):
                self.timeouts: list[float | None] = []

            def join(self, timeout=None):
                self.timeouts.append(timeout)

        refresh_thread = _Thread()
        flush_thread = _Thread()
        client._refresh_thread = refresh_thread
        client._flush_thread = flush_thread

        client.shutdown()

        assert refresh_thread.timeouts == [7.0]
        assert flush_thread.timeouts == [7.0]

    def test_unadopted_guard_is_always_open(self):
        client = make_client()
        client._set_guards([make_guard(adopted=False)])
        assert client.is_open("test.guard") is True

    def test_unadopted_guard_is_reported_once(self, monkeypatch):
        client = make_client()
        client._set_guards([make_guard(adopted=False)])
        captured: list[dict[str, object]] = []

        class _Resp:
            status = 204

            def read(self):
                return b""

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            captured.append({
                "url": req.full_url,
                "body": json.loads(req.data.decode()),
            })
            return _Resp()

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        assert client.is_open("test.guard") is True
        assert client.is_open("test.guard") is True
        observations = client._get_pending_unadopted_guards()
        assert len(observations) == 1
        assert observations[0].guard_name == "test.guard"
        assert observations[0].check_count == 2

        client.flush_signals()

        assert captured == [
            {
                "url": "http://localhost:9999/api/v1/unadopted-guards",
                "body": {
                    "projectClientToken": "test-key",
                    "environment": "",
                    "observations": [
                        {
                            "guardName": "test.guard",
                            "checkCount": 2,
                            "firstSeenMs": observations[0].first_seen_ms,
                            "lastSeenMs": observations[0].last_seen_ms,
                            "estimatedChecksPerMinute": observations[0].estimated_checks_per_minute,
                        }
                    ],
                },
            }
        ]
        assert client._get_pending_unadopted_guards() == []

    def test_unknown_guard_is_always_open(self):
        client = make_client()
        client._set_guards([])
        assert client.is_open("unknown.guard") is True

    def test_fallback_is_used_before_initial_load(self):
        client = make_client()
        assert client.is_open("unknown.guard") is False
        assert client.is_open("unknown.guard", Options(disable_measurement=False, fallback=True)) is True

    def test_nonpositive_options_fall_back_to_defaults(self):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="",
                refresh_rate_seconds=0,
                flush_rate_seconds=-1,
                flush_size=0,
                http_timeout_seconds=0,
                flush_buffer_multiplier=-2,
            ),
        )

        assert client._backend_url() == "https://api.liteguard.io"
        assert client._configured_refresh_rate_seconds() == 30
        assert client._configured_flush_rate_seconds() == 10
        assert client._flush_size() == 500
        assert client._http_timeout_seconds() == 4
        assert client._flush_buffer_multiplier() == 4
        assert client._max_buffer_size() == 2000

    def test_evaluated_result_from_rules(self):
        client = make_client()
        client._set_guards(
            [
                make_guard(
                    rules=[make_rule(result=True)],
                    default_value=False,
                )
            ]
        )
        client.add_properties({"plan": "pro"})
        assert client.is_open("test.guard") is True
        client.reset_properties()
        client.add_properties({"plan": "free"})
        assert client.is_open("test.guard") is False

    def test_per_call_properties_override_context(self):
        client = make_client()
        client._set_guards(
            [
                make_guard(
                    rules=[
                        make_rule(
                            property_name="userId",
                            operator=Operator.IN,
                            values=["canary-1"],
                            result=True,
                        )
                    ],
                    default_value=False,
                )
            ]
        )
        client.add_properties({"userId": "normal-user"})
        assert client.is_open("test.guard", Options(disable_measurement=False, properties={"userId": "canary-1"})) is True
        assert client.is_open("test.guard") is False

    def test_explicit_scope_evaluates_with_request_local_properties(self):
        client = make_client()
        client._set_guards(
            [
                make_guard(
                    name="feature.plan",
                    rules=[make_rule(result=True)],
                    default_value=False,
                ),
                make_guard(
                    name="feature.canary",
                    rules=[
                        make_rule(
                            property_name="userId",
                            operator=Operator.IN,
                            values=["canary-1"],
                            result=True,
                        )
                    ],
                    default_value=False,
                ),
            ]
        )

        scope = client.create_scope({"plan": "free", "userId": "normal-user"})

        assert scope.is_open("feature.plan") is False
        assert scope.is_open(
            "feature.canary",
            Options(disable_measurement=False, properties={"userId": "canary-1"}),
        ) is True
        assert client.get_context() == {}

    def test_contextvars_isolate_overlapping_async_request_scopes(self):
        client = make_client()
        client._set_guards(
            [
                make_guard(
                    name="feature.plan",
                    rules=[make_rule(result=True)],
                    default_value=False,
                )
            ]
        )

        async def evaluate(plan: str) -> bool:
            async def run_request() -> bool:
                client.add_properties({"plan": plan})
                await asyncio.sleep(0)
                return client.is_open("feature.plan")

            return await client.run_with_scope(client.create_scope({"requestId": plan}), run_request)

        async def main() -> tuple[bool, bool]:
            return await asyncio.gather(evaluate("free"), evaluate("pro"))

        free_result, pro_result = asyncio.run(main())

        assert free_result is False
        assert pro_result is True
        assert client.get_active_scope().get_properties() == {}

    def test_add_properties_merges(self):
        client = make_client()
        client.add_properties({"a": "1", "b": "2"})
        client.add_properties({"b": "overwritten", "c": "3"})
        assert client.get_context() == {"a": "1", "b": "overwritten", "c": "3"}

    def test_clear_properties(self):
        client = make_client()
        client.add_properties({"a": "1", "b": "2", "c": "3"})
        client.clear_properties(["a", "c"])
        assert client.get_context() == {"b": "2"}

    def test_reset_properties(self):
        client = make_client()
        client.add_properties({"a": "1"})
        client.reset_properties()
        assert client.get_context() == {}

    def test_bind_protected_context_refetches_with_post_body(self, monkeypatch):
        client = make_client()
        captured = {}

        class _Resp:
            status = 200

            def read(self):
                return b'{"guards": [], "refreshRateSeconds": 30, "etag": "etag-1"}'

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            captured["url"] = req.full_url
            captured["method"] = req.get_method()
            captured["body"] = json.loads(req.data.decode())
            return _Resp()

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        protected_context = ProtectedContext(
            properties={"email": "alice@acme.com"},
            signature="sig-123",
        )
        client.bind_protected_context(protected_context)

        assert client._get_protected_context() == protected_context
        assert captured == {
            "url": "http://localhost:9999/api/v1/guards",
            "method": "POST",
            "body": {
                "projectClientToken": "test-key",
                "environment": "",
                "protectedContext": {
                    "properties": {"email": "alice@acme.com"},
                    "signature": "sig-123",
                },
            },
        }

    def test_clear_protected_context_refetches_public_bundle(self, monkeypatch):
        client = make_client()
        bodies = []

        class _Resp:
            status = 200

            def __init__(self, etag: str):
                self._etag = etag

            def read(self):
                return (
                    '{"guards": [], "refreshRateSeconds": 30, '
                    f'"etag": "{self._etag}"}}'
                ).encode()

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            bodies.append(json.loads(req.data.decode()))
            return _Resp(f"etag-{len(bodies)}")

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        client.bind_protected_context(ProtectedContext(properties={"org": "acme"}, signature="sig-123"))
        client.clear_protected_context()

        assert client._get_protected_context() is None
        assert bodies[1] == {
            "projectClientToken": "test-key",
            "environment": "",
        }

    def test_bind_protected_context_returns_derived_scope_and_caches_bundles(self, monkeypatch):
        client = make_client()
        client._set_guards([make_guard(name="private.dashboard", default_value=False)])
        captured: list[dict[str, object]] = []

        class _Resp:
            status = 200

            def __init__(self, etag: str):
                self._etag = etag

            def read(self):
                return (
                    '{"guards": [{"name": "private.dashboard", "rules": [], '
                    '"defaultValue": true, "adopted": true, "rateLimitPerMinute": 0, '
                    '"rateLimitProperties": []}], '
                    f'"refreshRateSeconds": 30, "etag": "{self._etag}"}}'
                ).encode()

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            body = json.loads(req.data.decode())
            captured.append(body)
            etag = f"etag-{len(captured)}"
            return _Resp(etag)

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        protected_context = ProtectedContext(
            properties={"email": "alice@acme.com"},
            signature="sig-123",
        )

        public_scope = client.create_scope()
        protected_scope = public_scope.bind_protected_context(protected_context)
        cached_scope = client.create_scope().bind_protected_context(
            ProtectedContext(properties={"email": "alice@acme.com"}, signature="sig-123")
        )
        other_scope = client.create_scope().bind_protected_context(
            ProtectedContext(properties={"email": "bob@acme.com"}, signature="sig-456")
        )

        assert len(captured) == 2
        assert captured[0] == {
            "projectClientToken": "test-key",
            "environment": "",
            "protectedContext": {
                "properties": {"email": "alice@acme.com"},
                "signature": "sig-123",
            },
        }
        assert protected_scope.is_open("private.dashboard") is True
        assert cached_scope.is_open("private.dashboard") is True
        assert other_scope.is_open("private.dashboard") is True
        assert public_scope.is_open("private.dashboard") is False
        assert protected_scope.clear_protected_context().is_open("private.dashboard") is False

    def test_environment_is_included_in_signal_and_unadopted_requests(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999/",
                environment="production",
            ),
        )
        client._set_guards([
            make_guard(name="feature.open", default_value=True),
            make_guard(name="feature.unadopted", adopted=False),
        ])
        captured: list[dict[str, object]] = []

        class _Resp:
            status = 204

            def read(self):
                return b""

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            captured.append({
                "url": req.full_url,
                "body": json.loads(req.data.decode()),
                "headers": dict(req.header_items()),
                "timeout": timeout,
            })
            return _Resp()

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        assert client.is_open("feature.open") is True
        assert client.is_open("feature.unadopted") is True
        client.flush_signals()

        assert [item["url"] for item in captured] == [
            "http://localhost:9999/api/v1/signals",
            "http://localhost:9999/api/v1/unadopted-guards",
        ]
        assert [item["timeout"] for item in captured] == [4, 4]
        assert captured[0]["headers"]["X-liteguard-environment"] == "production"
        assert captured[0]["body"]["projectClientToken"] == "test-key"
        assert captured[0]["body"]["environment"] == "production"
        assert captured[0]["body"]["signals"][0]["guardName"] == "feature.open"
        assert "measurement" in captured[0]["body"]["signals"][0]
        assert captured[1]["headers"]["X-liteguard-environment"] == "production"
        assert captured[1]["body"] == {
            "projectClientToken": "test-key",
            "environment": "production",
            "observations": [
                {
                    "guardName": "feature.unadopted",
                    "checkCount": 1,
                    "firstSeenMs": captured[1]["body"]["observations"][0]["firstSeenMs"],
                    "lastSeenMs": captured[1]["body"]["observations"][0]["lastSeenMs"],
                    "estimatedChecksPerMinute": captured[1]["body"]["observations"][0]["estimatedChecksPerMinute"],
                }
            ],
        }

    def test_environment_is_included_in_guard_requests(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999/",
                environment="production",
            ),
        )
        captured: list[dict[str, object]] = []

        class _Resp:
            status = 200

            def read(self):
                return b'{"guards": [], "refreshRateSeconds": 30, "etag": "etag-1"}'

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            captured.append({
                "url": req.full_url,
                "body": json.loads(req.data.decode()),
                "headers": dict(req.header_items()),
                "timeout": timeout,
            })
            return _Resp()

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        client.start()

        assert [item["url"] for item in captured] == ["http://localhost:9999/api/v1/guards"]
        assert [item["timeout"] for item in captured] == [4]
        assert captured[0]["body"] == {
            "projectClientToken": "test-key",
            "environment": "production",
        }
        assert captured[0]["headers"]["Authorization"] == "Bearer test-key"
        assert captured[0]["headers"]["Content-type"] == "application/json"
        assert captured[0]["headers"]["X-liteguard-environment"] == "production"

    def test_log_only_emits_when_quiet_is_disabled(self, monkeypatch):
        messages: list[str] = []

        def fake_warning(message, *args):
            messages.append(message % args if args else message)

        monkeypatch.setattr("liteguard.client.logger.warning", fake_warning)

        quiet_client = LiteguardClient("test-key", ClientOptions(backend_url="http://localhost:9999", quiet=True))
        noisy_client = LiteguardClient("test-key", ClientOptions(backend_url="http://localhost:9999", quiet=False))

        quiet_client._log("warning", "quiet %s", "ignored")
        noisy_client._log("warning", "hello %s", "world")

        assert messages == ["hello world"]

    def test_custom_flush_buffer_multiplier_caps_requeued_signals(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999",
                flush_size=2,
                flush_buffer_multiplier=2,
            ),
        )
        client._set_guards([make_guard(default_value=True)])

        def fake_urlopen(req, timeout=0):
            raise RuntimeError("network down")

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        for _ in range(5):
            assert client.is_open("test.guard") is True

        assert len(client._signal_buffer) == 4

    def test_buffer_threshold_flush_does_not_block_hot_path(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999",
                flush_size=1,
                flush_rate_seconds=3600,
                http_timeout_seconds=1,
            ),
        )

        signal_request_started = threading.Event()

        class _Resp:
            status = 204

            def read(self):
                return b""

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            if req.full_url.endswith("/api/v1/guards"):
                class _GuardsResp(_Resp):
                    status = 200

                    def read(self):
                        return b'{"guards": [], "refreshRateSeconds": 30, "etag": "etag-1"}'

                return _GuardsResp()

            signal_request_started.set()
            time.sleep(0.2)
            return _Resp()

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        client.start()
        try:
            client._set_guards([make_guard(default_value=True)])
            start = time.monotonic()
            assert client.is_open("test.guard") is True
            assert time.monotonic() - start < 0.1

            assert signal_request_started.wait(timeout=1.0)
        finally:
            client.shutdown()

    def test_signal_includes_tracing_metadata(self):
        client = make_client()
        client._set_guards([make_guard(default_value=True)])

        assert client.is_open("test.guard") is True

        signal = client._signal_buffer[0]
        assert signal.signal_id
        assert signal.execution_id
        assert signal.sequence_number == 1
        assert signal.callsite_id
        assert signal.kind == "guard_check"
        assert signal.dropped_signals_since_last == 0
        assert signal.measurement is not None
        assert signal.measurement.guard_check is not None

    def test_guard_check_measurement_is_captured_when_enabled(self, monkeypatch):
        client = make_client()
        client._set_guards([make_guard(default_value=True)])

        monkeypatch.setattr(
            client,
            "_capture_guard_check_measurement",
            lambda: SignalPerformance(
                guard_check=GuardCheckPerformance(
                    rss_bytes=111,
                    cpu_time_ns=222,
                    gc_count=3,
                )
            ),
        )

        assert client.is_open("test.guard") is True

        signal = client._signal_buffer[0]
        assert signal.measurement is not None
        assert signal.measurement.guard_check == GuardCheckPerformance(
            rss_bytes=111,
            cpu_time_ns=222,
            gc_count=3,
        )

    def test_ru_maxrss_is_normalized_to_bytes(self, monkeypatch):
        client = make_client()

        class _Usage:
            ru_maxrss = 123

        monkeypatch.setattr("liteguard.client.resource.getrusage", lambda who: _Usage())
        monkeypatch.setattr("liteguard.client.sys.platform", "linux")

        assert client._read_rss_bytes() == 123 * 1024

    def test_execution_scope_links_signals(self):
        client = make_client()
        client._set_guards([make_guard(name="guard.a", default_value=True), make_guard(name="guard.b", default_value=True)])

        with client.execution():
            assert client.is_open("guard.a") is True
            assert client.is_open("guard.b") is True

        first, second = client._signal_buffer
        assert first.execution_id == second.execution_id
        assert first.sequence_number == 1
        assert second.sequence_number == 2
        assert second.parent_signal_id == first.signal_id

    def test_execute_if_open_returns_none_and_skips_callback_when_closed(self):
        client = make_client()
        client._set_guards([make_guard(name="guard.closed", default_value=False)])

        calls = 0

        def callback():
            nonlocal calls
            calls += 1
            return "ran"

        result = client.execute_if_open("guard.closed", callback)

        assert result is None
        assert calls == 0
        assert len(client._signal_buffer) == 1
        assert client._signal_buffer[0].guard_name == "guard.closed"

    def test_execute_if_open_executes_callback_inside_linked_execution(self):
        client = make_client()
        client._set_guards([
            make_guard(name="guard.a", default_value=True),
            make_guard(name="guard.b", default_value=True),
        ])

        result = client.execute_if_open("guard.a", lambda: client.is_open("guard.b"))

        assert result is True
        first, second, third = client._signal_buffer
        assert first.execution_id == second.execution_id
        assert second.execution_id == third.execution_id
        assert first.sequence_number == 1
        assert second.sequence_number == 2
        assert third.sequence_number == 3
        assert second.parent_signal_id == first.signal_id
        assert third.kind == "guard_execution"
        assert third.parent_signal_id == first.signal_id

    def test_execute_if_open_awaitable_preserves_execution_context_until_await_finishes(self):
        client = make_client()
        client._set_guards([
            make_guard(name="guard.a", default_value=True),
            make_guard(name="guard.b", default_value=True),
        ])

        async def callback():
            await asyncio.sleep(0)
            return client.is_open("guard.b")

        result = asyncio.run(client.execute_if_open("guard.a", callback))

        assert result is True
        first, second, third = client._signal_buffer
        assert first.execution_id == second.execution_id
        assert second.execution_id == third.execution_id
        assert first.sequence_number == 1
        assert second.sequence_number == 2
        assert third.sequence_number == 3
        assert second.parent_signal_id == first.signal_id
        assert third.kind == "guard_execution"
        assert third.parent_signal_id == first.signal_id

    def test_execute_if_open_awaitable_records_failure_after_await(self):
        client = make_client()
        client._set_guards([make_guard(name="guard.exec", default_value=True)])

        async def callback():
            await asyncio.sleep(0)
            raise RuntimeError("boom")

        pending = client.execute_if_open("guard.exec", callback)

        assert len(client._signal_buffer) == 1
        assert client._signal_buffer[0].kind == "guard_check"

        with pytest.raises(RuntimeError, match="boom"):
            asyncio.run(pending)

        check_signal, execution_signal = client._signal_buffer
        assert execution_signal.kind == "guard_execution"
        assert execution_signal.parent_signal_id == check_signal.signal_id
        assert execution_signal.measurement is not None
        assert execution_signal.measurement.guard_execution is not None
        assert execution_signal.measurement.guard_execution.completed is False
        assert execution_signal.measurement.guard_execution.error_class == "RuntimeError"

    def test_guard_execution_measurement_is_captured_when_enabled(self, monkeypatch):
        client = make_client()
        client._set_guards([make_guard(name="guard.exec", default_value=True)])

        monkeypatch.setattr(
            client,
            "_capture_guard_execution_measurement",
            lambda started_at_ns, completed, exc=None: SignalPerformance(
                guard_execution=GuardExecutionPerformance(
                    duration_ns=999,
                    completed=completed,
                    rss_end_bytes=123,
                    cpu_time_end_ns=456,
                    gc_count_end=7,
                    error_class=exc.__class__.__name__ if exc else None,
                )
            ),
        )

        assert client.execute_if_open("guard.exec", lambda: "done") == "done"

        check_signal, execution_signal = client._signal_buffer
        assert check_signal.kind == "guard_check"
        assert execution_signal.kind == "guard_execution"
        assert execution_signal.parent_signal_id == check_signal.signal_id
        assert execution_signal.measurement is not None
        assert execution_signal.measurement.guard_execution == GuardExecutionPerformance(
            duration_ns=999,
            completed=True,
            rss_end_bytes=123,
            cpu_time_end_ns=456,
            gc_count_end=7,
            error_class=None,
        )

    def test_measurement_is_omitted_when_disabled_by_client_check_or_guard(self):
        cases = [
            (
                LiteguardClient("test-key", ClientOptions(backend_url="http://localhost:9999", disable_measurement=True)),
                make_guard(name="guard.client", default_value=True),
                None,
            ),
            (
                make_client(),
                make_guard(name="guard.check", default_value=True),
                Options(disable_measurement=True),
            ),
            (
                make_client(),
                make_guard(name="guard.server", default_value=True, disable_measurement=True),
                None,
            ),
        ]

        for client, guard, options in cases:
            client._set_guards([guard])
            assert client.execute_if_open(guard.name, lambda: "done", options) == "done"
            assert len(client._signal_buffer) == 2
            assert client._signal_buffer[0].measurement is None
            assert client._signal_buffer[1].measurement is None

    def test_signals_outside_execution_scope_are_standalone(self):
        client = make_client()
        client._set_guards([make_guard(name="guard.a", default_value=True), make_guard(name="guard.b", default_value=True)])

        assert client.is_open("guard.a") is True
        assert client.is_open("guard.b") is True

        first, second = client._signal_buffer
        assert first.sequence_number == 1
        assert second.sequence_number == 1
        assert first.parent_signal_id is None
        assert second.parent_signal_id is None
        assert first.execution_id != second.execution_id

    def test_nested_execution_scope_reuses_active_execution(self):
        client = make_client()
        client._set_guards([make_guard(name="guard.a", default_value=True), make_guard(name="guard.b", default_value=True)])

        with client.execution():
            assert client.is_open("guard.a") is True
            with client.execution():
                assert client.is_open("guard.b") is True

        first, second = client._signal_buffer
        assert first.execution_id == second.execution_id
        assert first.sequence_number == 1
        assert second.sequence_number == 2
        assert second.parent_signal_id == first.signal_id

    def test_buffer_drops_oldest_signal_when_full(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999",
                flush_size=2,
                flush_buffer_multiplier=2,
            ),
        )
        client._set_guards([make_guard(default_value=True)])

        def fake_urlopen(req, timeout=0):
            raise RuntimeError("network down")

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        for _ in range(5):
            assert client.is_open("test.guard") is True

        assert len(client._signal_buffer) == 4
        oldest_signal_id = client._signal_buffer[0].signal_id

        assert client.is_open("test.guard") is True

        assert len(client._signal_buffer) == 4
        assert all(signal.signal_id != oldest_signal_id for signal in client._signal_buffer)
        assert client._signal_buffer[-1].dropped_signals_since_last >= 1

    def test_http_timeout_seconds_overrides_request_timeout(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999",
                http_timeout_seconds=9,
            ),
        )
        client._set_guards([make_guard(default_value=True)])
        captured: dict[str, int] = {}

        class _Resp:
            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            captured["timeout"] = timeout
            return _Resp()

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        assert client.is_open("test.guard") is True
        client.flush_signals()

        assert captured["timeout"] == 9

    def test_server_refresh_rate_can_slow_down_refreshes(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999",
                refresh_rate_seconds=30,
            ),
        )

        class _Resp:
            status = 200

            def read(self):
                return b'{"guards": [], "refreshRateSeconds": 90, "etag": "etag-1"}'

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=0: _Resp())

        client._fetch_guards()

        assert client._current_refresh_rate_seconds == 90

    def test_server_refresh_rate_never_goes_below_client_floor(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999",
                refresh_rate_seconds=30,
            ),
        )

        class _Resp:
            status = 200

            def read(self):
                return b'{"guards": [], "refreshRateSeconds": 5, "etag": "etag-1"}'

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=0: _Resp())

        client._fetch_guards()

        assert client._current_refresh_rate_seconds == 30

    def test_invalid_refresh_rate_payload_is_ignored_without_clobbering_cached_guards(self, monkeypatch):
        client = make_client()
        existing_guard = make_guard(name="guard.cached", default_value=True)
        client._set_guards([existing_guard])

        class _Resp:
            status = 200

            def read(self):
                return b'{"guards": [], "refreshRateSeconds": "not-a-number", "etag": "etag-1"}'

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=0: _Resp())

        client._fetch_guards()

        assert client._bundles[""].ready is True
        assert list(client._bundles[""].guards) == ["guard.cached"]
        assert client._current_refresh_rate_seconds == client._options.refresh_rate_seconds

    def test_invalid_guard_payload_is_ignored_without_clobbering_cached_guards(self, monkeypatch):
        client = make_client()
        existing_guard = make_guard(name="guard.cached", default_value=True)
        client._set_guards([existing_guard])

        class _Resp:
            status = 200

            def read(self):
                return json.dumps(
                    {
                        "guards": [
                            {
                                "name": "guard.bad",
                                "defaultValue": True,
                                "adopted": True,
                                "rateLimitPerMinute": 0,
                                "rules": [
                                    {
                                        "propertyName": "plan",
                                        "operator": "NOT_A_REAL_OPERATOR",
                                        "values": ["pro"],
                                        "result": True,
                                        "enabled": True,
                                    }
                                ],
                            }
                        ],
                        "refreshRateSeconds": 30,
                        "etag": "etag-2",
                    }
                ).encode()

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=0: _Resp())

        client._fetch_guards()

        assert client._bundles[""].ready is True
        assert list(client._bundles[""].guards) == ["guard.cached"]

    def test_shortest_cached_bundle_refresh_rate_wins(self, monkeypatch):
        client = LiteguardClient(
            "test-key",
            ClientOptions(
                backend_url="http://localhost:9999",
                refresh_rate_seconds=30,
            ),
        )
        responses = iter(
            [
                b'{"guards": [], "refreshRateSeconds": 90, "etag": "public-1"}',
                b'{"guards": [], "refreshRateSeconds": 45, "etag": "protected-1"}',
            ]
        )

        class _Resp:
            status = 200

            def __init__(self, payload: bytes):
                self._payload = payload

            def read(self):
                return self._payload

            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return None

        def fake_urlopen(req, timeout=0):
            return _Resp(next(responses))

        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        client._fetch_guards()
        assert client._get_refresh_rate_seconds() == 90

        client.create_scope().bind_protected_context(
            ProtectedContext(properties={"email": "alice@acme.com"}, signature="sig-123")
        )
        assert client._get_refresh_rate_seconds() == 45


class TestRateLimit:
    def _rl_guard(self, limit: int) -> Guard:
        return make_guard(default_value=True, rate_limit_per_minute=limit)

    def test_enforces_limit(self):
        client = make_client()
        client._set_guards([self._rl_guard(2)])
        assert client.is_open("test.guard") is True   # 1st
        assert client.is_open("test.guard") is True   # 2nd
        assert client.is_open("test.guard") is False  # 3rd, limit reached

    def test_closed_eval_does_not_consume_quota(self):
        client = make_client()
        client._set_guards([
            make_guard(
                rules=[make_rule(result=True)],
                default_value=False,
                rate_limit_per_minute=2,
            )
        ])
        # Closed calls (plan=free) must not consume quota
        client.add_properties({"plan": "free"})
        for _ in range(5):
            client.is_open("test.guard")
        # Open calls should still get their full quota
        client.reset_properties()
        client.add_properties({"plan": "pro"})
        assert client.is_open("test.guard") is True
        assert client.is_open("test.guard") is True
        assert client.is_open("test.guard") is False

    def test_zero_limit_means_unlimited(self):
        client = make_client()
        client._set_guards([self._rl_guard(0)])
        for _ in range(100):
            assert client.is_open("test.guard") is True

    def test_window_reset_clears_counter(self):
        client = make_client()
        client._set_guards([self._rl_guard(1)])
        assert client.is_open("test.guard") is True   # consumes quota
        assert client.is_open("test.guard") is False  # blocked
        client._reset_rate_limit_state("test.guard")  # simulate new window
        assert client.is_open("test.guard") is True   # fresh window

    def test_bucketed_by_single_property(self):
        client = make_client()
        client._set_guards([make_guard(default_value=True, rate_limit_per_minute=1, rate_limit_properties=["region"])])
        us_east = Options(disable_measurement=False, properties={"region": "us-east"})
        assert client.is_open("test.guard", us_east) is True
        assert client.is_open("test.guard", us_east) is False  # blocked
        # Different bucket. Own fresh counter
        eu_west = Options(disable_measurement=False, properties={"region": "eu-west"})
        assert client.is_open("test.guard", eu_west) is True
        assert client.is_open("test.guard", eu_west) is False

    def test_bucketed_by_multiple_properties(self):
        client = make_client()
        client._set_guards([make_guard(default_value=True, rate_limit_per_minute=1,
                                       rate_limit_properties=["customer_domain", "region"])])
        props_a = {"customer_domain": "acme.com", "region": "us-east"}
        props_b = {"customer_domain": "acme.com", "region": "eu-west"}
        assert client.is_open("test.guard", Options(disable_measurement=False, properties=props_a)) is True
        assert client.is_open("test.guard", Options(disable_measurement=False, properties=props_a)) is False  # A blocked
        assert client.is_open("test.guard", Options(disable_measurement=False, properties=props_b)) is True   # B fresh

    def test_empty_properties_uses_global_bucket(self):
        client = make_client()
        client._set_guards([make_guard(default_value=True, rate_limit_per_minute=1, rate_limit_properties=[])])
        assert client.is_open("test.guard", Options(disable_measurement=False, properties={"region": "us"})) is True
        # Different region value, but no bucketing. Same global counter
        assert client.is_open("test.guard", Options(disable_measurement=False, properties={"region": "eu"})) is False
