"""Core SDK client: request scopes, guard cache, refresh, and signal buffering."""

from __future__ import annotations

import contextlib
import contextvars
import gc
import inspect
import itertools
import json
import logging
import sys
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any, Callable, TypeVar

try:
    import resource
except ImportError:  # pragma: no cover - platform dependent
    resource = None

from .evaluation import evaluate_guard
from .types import (
    ClientOptions,
    GetGuardsRequest,
    Guard,
    GuardCheckPerformance,
    GuardExecutionPerformance,
    Options,
    Operator,
    Properties,
    ProtectedContext,
    Rule,
    UnadoptedGuardObservation,
    SendUnadoptedGuardsRequest,
    Signal,
    SignalPerformance,
)

logger = logging.getLogger("liteguard")

DEFAULT_BACKEND_URL = "https://api.liteguard.io"
DEFAULT_REFRESH_RATE_SECONDS = 30
DEFAULT_FLUSH_RATE_SECONDS = 10
DEFAULT_FLUSH_SIZE = 500
DEFAULT_HTTP_TIMEOUT_SECONDS = 4
DEFAULT_FLUSH_BUFFER_MULTIPLIER = 4
PUBLIC_BUNDLE_KEY = ""

T = TypeVar("T")
_KEEP = object()


@dataclass
class _ExecutionState:
    execution_id: str
    sequence_number: int = 0
    last_signal_id: str | None = None


_execution_state: contextvars.ContextVar[_ExecutionState | None] = contextvars.ContextVar(
    "liteguard_execution_state", default=None
)
_signal_counter = itertools.count(1)


@dataclass
class _RateLimitEntry:
    window_start: float
    count: int


@dataclass
class _BufferedSignalInput:
    guard_name: str
    result: bool
    properties: Properties
    callsite_id: str
    kind: str
    measurement: SignalPerformance | None = None
    parent_signal_id_override: str | None = None


@dataclass
class _PendingUnadoptedGuardObservation:
    guard_name: str
    first_seen_ms: int
    last_seen_ms: int
    check_count: int


@dataclass
class _GuardBundle:
    key: str
    guards: dict[str, Guard]
    ready: bool
    etag: str
    protected_context: ProtectedContext | None
    refresh_rate_seconds: int


def _clone_properties(properties: Properties) -> Properties:
    return dict(properties)


def _clone_protected_context(protected_context: ProtectedContext | None) -> ProtectedContext | None:
    if protected_context is None:
        return None
    return ProtectedContext(
        properties=dict(protected_context.properties),
        signature=protected_context.signature,
    )


def _protected_context_cache_key(protected_context: ProtectedContext | None) -> str:
    if protected_context is None:
        return PUBLIC_BUNDLE_KEY
    keys = sorted(protected_context.properties)
    parts = [protected_context.signature, ""]
    for key in keys:
        parts.append(f"{key}={protected_context.properties.get(key, '')}")
    return "\x00".join(parts)


class LiteguardScope:
    """Immutable request-local context used for guard evaluation.

    A scope contains the properties and optional protected context that should
    apply to a unit of work, such as a web request, background job, or CLI
    command. All mutating helpers return a new scope instead of changing the
    existing instance.
    """

    def __init__(
        self,
        client: LiteguardClient,
        properties: Properties,
        protected_bundle_key: str = PUBLIC_BUNDLE_KEY,
        protected_context: ProtectedContext | None = None,
    ) -> None:
        self._client = client
        self._properties = _clone_properties(properties)
        self._protected_bundle_key = protected_bundle_key
        self._protected_context = _clone_protected_context(protected_context)

    def with_properties(self, properties: Properties) -> LiteguardScope:
        """Return a new scope with `properties` overlaid on this scope."""
        return self._client._derive_scope(
            self,
            properties={**self._properties, **properties},
        )

    def add_properties(self, properties: Properties) -> LiteguardScope:
        """Alias for `with_properties()` for incremental context building."""
        return self.with_properties(properties)

    def clear_properties(self, names: list[str]) -> LiteguardScope:
        """Return a new scope without the property names listed in `names`."""
        next_properties = _clone_properties(self._properties)
        for name in names:
            next_properties.pop(name, None)
        return self._client._derive_scope(self, properties=next_properties)

    def reset_properties(self) -> LiteguardScope:
        """Return a new scope with an empty property set."""
        return self._client._derive_scope(self, properties={})

    def bind_protected_context(self, protected_context: ProtectedContext) -> LiteguardScope:
        """Return a new scope bound to `protected_context`.

        Binding may trigger a protected-bundle fetch the first time this exact
        protected context is used.
        """
        return self._client._bind_protected_context_to_scope(self, protected_context)

    def clear_protected_context(self) -> LiteguardScope:
        """Return a new scope that uses the public guard bundle only."""
        self._client._ensure_public_bundle_ready()
        return self._client._derive_scope(
            self,
            protected_bundle_key=PUBLIC_BUNDLE_KEY,
            protected_context=None,
        )

    def is_open(self, name: str, options: Options | None = None) -> bool:
        """Return whether guard `name` is open for this scope."""
        return self._client._evaluate_is_open_for_scope(self, name, options, emit_signal=True)

    def peek_is_open(self, name: str, options: Options | None = None) -> bool:
        """Return whether guard `name` is open without emitting a signal."""
        return self._client._evaluate_is_open_for_scope(self, name, options, emit_signal=False)

    def execute_if_open(
        self,
        name: str,
        fn: Callable[[], T],
        options: Options | None = None,
    ) -> T | None:
        """Run `fn` if guard `name` is open for this scope.

        Returns the callback result when open, otherwise `None`.
        """
        return self._client._execute_if_open_for_scope(self, name, fn, options)

    def run(self, fn: Callable[[], T]) -> T:
        """Run `fn` with this scope installed as the active request scope."""
        return self._client.run_with_scope(self, fn)

    def get_properties(self) -> Properties:
        """Return a copy of this scope's properties."""
        return _clone_properties(self._properties)

    def get_protected_context(self) -> ProtectedContext | None:
        """Return a copy of this scope's protected context, if present."""
        return _clone_protected_context(self._protected_context)

    def _get_bundle_key(self) -> str:
        return self._protected_bundle_key

    def _belongs_to(self, client: LiteguardClient) -> bool:
        return self._client is client


class LiteguardClient:
    """Client that manages guard bundles, request scopes, and telemetry.

    Create one client per independent Liteguard lifecycle.
    """

    def __init__(self, project_client_token: str, options: ClientOptions) -> None:
        self._project_client_token = project_client_token
        self._options = options

        self._bundles: dict[str, _GuardBundle] = {
            PUBLIC_BUNDLE_KEY: self._create_empty_bundle(PUBLIC_BUNDLE_KEY, None)
        }
        self._default_scope = LiteguardScope(self, {})
        self._request_scope: contextvars.ContextVar[LiteguardScope | None] = contextvars.ContextVar(
            f"liteguard_request_scope_{id(self)}", default=None
        )

        self._lock = threading.RLock()
        self._signal_buffer: list[Signal] = []
        self._dropped_signals_pending = 0
        self._pending_unadopted_guards: dict[str, _PendingUnadoptedGuardObservation] = {}

        self._stop_event = threading.Event()
        self._refresh_wakeup_event = threading.Event()
        self._flush_wakeup_event = threading.Event()
        self._refresh_thread: threading.Thread | None = None
        self._flush_thread: threading.Thread | None = None

        self._rate_limit_state: dict[str, _RateLimitEntry] = {}
        self._current_refresh_rate_seconds = self._configured_refresh_rate_seconds()

    # -------------------------------------------------------------------------
    # Lifecycle
    # -------------------------------------------------------------------------

    def start(self) -> None:
        """Load the initial public bundle and start background maintenance threads."""
        self._fetch_guards_for_bundle(PUBLIC_BUNDLE_KEY)

        self._refresh_thread = threading.Thread(
            target=self._refresh_loop, daemon=True, name="liteguard-refresh"
        )
        self._flush_thread = threading.Thread(
            target=self._flush_loop, daemon=True, name="liteguard-flush"
        )
        self._refresh_thread.start()
        self._flush_thread.start()

    def shutdown(self) -> None:
        """Stop background threads and perform a final signal flush."""
        self._stop_event.set()
        self._refresh_wakeup_event.set()
        self._flush_wakeup_event.set()
        shutdown_timeout = float(self._http_timeout_seconds())
        if self._refresh_thread:
            self._refresh_thread.join(timeout=shutdown_timeout)
        if self._flush_thread:
            self._flush_thread.join(timeout=shutdown_timeout)
        self.flush_signals()

    @contextlib.contextmanager
    def execution(self):
        """Group nested guard activity under a single execution ID.

        Nested uses reuse the current execution context instead of creating a
        new one.
        """
        existing = _execution_state.get()
        if existing is not None:
            yield
            return

        token = _execution_state.set(_ExecutionState(execution_id=_next_signal_id()))
        try:
            yield
        finally:
            _execution_state.reset(token)

    def with_execution(self, fn: Callable[[], T]) -> T:
        """Run `fn` inside an execution context.

        If `fn` returns an awaitable, the execution context remains active until
        the awaitable completes.
        """
        existing = _execution_state.get()
        if existing is not None:
            return fn()

        state = _ExecutionState(execution_id=_next_signal_id())
        token = _execution_state.set(state)
        try:
            result = fn()
        except Exception:
            _execution_state.reset(token)
            raise

        if inspect.isawaitable(result):
            _execution_state.reset(token)

            async def _await_result() -> Any:
                async_token = _execution_state.set(state)
                try:
                    return await result
                finally:
                    _execution_state.reset(async_token)

            return _await_result()  # type: ignore[return-value]

        _execution_state.reset(token)
        return result

    # -------------------------------------------------------------------------
    # Scope API
    # -------------------------------------------------------------------------

    def create_scope(self, properties: Properties | None = None) -> LiteguardScope:
        """Create a new immutable scope owned by this client."""
        return LiteguardScope(self, properties or {})

    def get_active_scope(self) -> LiteguardScope:
        """Return the context-local scope, or this client's default scope."""
        scope = self._request_scope.get()
        if scope is not None and scope._belongs_to(self):
            return scope
        return self._default_scope

    def run_with_scope(self, scope: LiteguardScope, fn: Callable[[], T]) -> T:
        """Run `fn` with `scope` set as the active scope for this context.

        If `fn` returns an awaitable, the scope binding is preserved until that
        awaitable finishes.
        """
        resolved = self._resolve_scope(scope)
        token = self._request_scope.set(resolved)
        try:
            result = fn()
        except Exception:
            self._request_scope.reset(token)
            raise

        if inspect.isawaitable(result):
            self._request_scope.reset(token)

            async def _await_result() -> Any:
                async_token = self._request_scope.set(resolved)
                try:
                    return await result
                finally:
                    self._request_scope.reset(async_token)

            return _await_result()  # type: ignore[return-value]

        self._request_scope.reset(token)
        return result

    def with_properties(self, properties: Properties, fn: Callable[[], T]) -> T:
        """Run `fn` with a derived active scope that includes `properties`."""
        return self.run_with_scope(self.get_active_scope().with_properties(properties), fn)

    def with_protected_context(self, protected_context: ProtectedContext, fn: Callable[[], T]) -> T:
        """Run `fn` with a derived active scope bound to `protected_context`."""
        return self.run_with_scope(self.get_active_scope().bind_protected_context(protected_context), fn)

    # -------------------------------------------------------------------------
    # Core API
    # -------------------------------------------------------------------------

    def is_open(
        self,
        name: str,
        options: Options | None = None,
    ) -> bool:
        """Return whether guard `name` is open for the active scope."""
        return self._evaluate_is_open_for_scope(self.get_active_scope(), name, options, emit_signal=True)

    def peek_is_open(
        self,
        name: str,
        options: Options | None = None,
    ) -> bool:
        """Return whether guard `name` is open without emitting telemetry."""
        return self._evaluate_is_open_for_scope(self.get_active_scope(), name, options, emit_signal=False)

    def _evaluate_is_open_for_scope(
        self,
        scope: LiteguardScope,
        name: str,
        options: Options | None,
        *,
        emit_signal: bool,
    ) -> bool:
        resolved_scope = self._resolve_scope(scope)
        options = options or Options(disable_measurement=False)
        fallback = options.fallback if options.fallback is not None else self._options.fallback

        with self._lock:
            bundle = self._bundle_for_scope(resolved_scope)
            if not bundle.ready:
                return fallback
            guard = bundle.guards.get(name)
            if guard is None:
                self._record_unadopted_guard(name)
                return True
            if not guard.adopted:
                self._record_unadopted_guard(name)
                return True

        props = {**resolved_scope.get_properties(), **options.properties}
        result = evaluate_guard(guard, props)
        if result and guard.rate_limit_per_minute > 0:
            if emit_signal:
                result = self._check_rate_limit(
                    name,
                    guard.rate_limit_per_minute,
                    guard.rate_limit_properties,
                    props,
                )
            else:
                result = self._would_pass_rate_limit(
                    name,
                    guard.rate_limit_per_minute,
                    guard.rate_limit_properties,
                    props,
                )

        if emit_signal:
            self._buffer_signal(
                _BufferedSignalInput(
                    guard_name=name,
                    result=result,
                    properties=props,
                    callsite_id=self._capture_callsite_id(),
                    kind="guard_check",
                    measurement=self._capture_guard_check_measurement()
                    if self._is_measurement_enabled(guard, options)
                    else None,
                )
            )
        return result

    def execute_if_open(
        self,
        name: str,
        fn: Callable[[], T],
        options: Options | None = None,
    ) -> T | None:
        """Run `fn` when guard `name` is open for the active scope."""
        return self._execute_if_open_for_scope(self.get_active_scope(), name, fn, options)

    def _execute_if_open_for_scope(
        self,
        scope: LiteguardScope,
        name: str,
        fn: Callable[[], T],
        options: Options | None = None,
    ) -> T | None:
        resolved_scope = self._resolve_scope(scope)
        options = options or Options(disable_measurement=False)
        props = {**resolved_scope.get_properties(), **options.properties}

        with self._lock:
            bundle = self._bundle_for_scope(resolved_scope)
            guard = bundle.guards.get(name) if bundle.ready else None

        def _run_guarded() -> T | None:
            if not self._evaluate_is_open_for_scope(resolved_scope, name, options, emit_signal=True):
                return None
            if guard is None or not guard.adopted:
                return fn()

            state = _execution_state.get()
            guard_check_signal_id = state.last_signal_id if state is not None else None
            measurement_enabled = self._is_measurement_enabled(guard, options)
            started_at = time.perf_counter_ns()

            try:
                value = fn()
            except Exception as exc:
                self._emit_guard_execution_signal(
                    name,
                    props,
                    guard_check_signal_id,
                    started_at,
                    measurement_enabled,
                    False,
                    exc,
                )
                raise

            if inspect.isawaitable(value):
                async def _await_value() -> Any:
                    try:
                        resolved_value = await value
                    except Exception as exc:
                        self._emit_guard_execution_signal(
                            name,
                            props,
                            guard_check_signal_id,
                            started_at,
                            measurement_enabled,
                            False,
                            exc,
                        )
                        raise

                    self._emit_guard_execution_signal(
                        name,
                        props,
                        guard_check_signal_id,
                        started_at,
                        measurement_enabled,
                        True,
                    )
                    return resolved_value

                return _await_value()  # type: ignore[return-value]

            self._emit_guard_execution_signal(
                name,
                props,
                guard_check_signal_id,
                started_at,
                measurement_enabled,
                True,
            )
            return value

        return self.with_execution(_run_guarded)

    def _emit_guard_execution_signal(
        self,
        name: str,
        props: Properties,
        guard_check_signal_id: str | None,
        started_at_ns: int,
        measurement_enabled: bool,
        completed: bool,
        exc: Exception | None = None,
    ) -> None:
        self._buffer_signal(
            _BufferedSignalInput(
                guard_name=name,
                result=True,
                properties=props,
                callsite_id=self._capture_callsite_id(),
                kind="guard_execution",
                measurement=self._capture_guard_execution_measurement(started_at_ns, completed, exc)
                if measurement_enabled
                else None,
                parent_signal_id_override=guard_check_signal_id,
            )
        )

    def _record_unadopted_guard(self, name: str) -> None:
        with self._lock:
            now = int(time.time() * 1000)
            observation = self._pending_unadopted_guards.get(name)
            if observation is None:
                self._pending_unadopted_guards[name] = _PendingUnadoptedGuardObservation(
                    guard_name=name,
                    first_seen_ms=now,
                    last_seen_ms=now,
                    check_count=1,
                )
                return
            observation.last_seen_ms = now
            observation.check_count += 1

    def add_properties(self, properties: Properties) -> LiteguardScope:
        """Overlay `properties` onto the active scope and return the new scope."""
        return self._replace_current_scope(self.get_active_scope().with_properties(properties))

    def clear_properties(self, names: list[str]) -> LiteguardScope:
        """Remove `names` from the active scope and return the new scope."""
        return self._replace_current_scope(self.get_active_scope().clear_properties(names))

    def reset_properties(self) -> LiteguardScope:
        """Replace the active scope with an equivalent empty-properties scope."""
        return self._replace_current_scope(self.get_active_scope().reset_properties())

    def bind_protected_context(self, protected_context: ProtectedContext) -> LiteguardScope:
        """Bind `protected_context` to the active scope and return the derived scope."""
        return self._replace_current_scope(self.get_active_scope().bind_protected_context(protected_context))

    def clear_protected_context(self) -> LiteguardScope:
        """Remove protected context from the active scope and return the result."""
        return self._replace_current_scope(self.get_active_scope().clear_protected_context())

    def get_context(self) -> Properties:
        """Return a copy of the active scope's properties."""
        return self.get_active_scope().get_properties()

    def _rate_limit_bucket_key(self, name: str, rate_limit_properties: list[str], props: Properties) -> str:
        if not rate_limit_properties:
            return name
        parts = [f"{p}={props.get(p, '')}" for p in rate_limit_properties]
        return name + "\x00" + "\x00".join(parts)

    def _check_rate_limit(
        self,
        name: str,
        limit_per_minute: int,
        rate_limit_properties: list[str],
        props: Properties,
    ) -> bool:
        now = time.monotonic()
        key = self._rate_limit_bucket_key(name, rate_limit_properties, props)
        with self._lock:
            entry = self._rate_limit_state.get(key, _RateLimitEntry(window_start=now, count=0))
            if now - entry.window_start >= 60.0:
                entry = _RateLimitEntry(window_start=now, count=0)
            if entry.count >= limit_per_minute:
                self._rate_limit_state[key] = entry
                return False
            self._rate_limit_state[key] = _RateLimitEntry(
                window_start=entry.window_start,
                count=entry.count + 1,
            )
            return True

    def _would_pass_rate_limit(
        self,
        name: str,
        limit_per_minute: int,
        rate_limit_properties: list[str],
        props: Properties,
    ) -> bool:
        now = time.monotonic()
        key = self._rate_limit_bucket_key(name, rate_limit_properties, props)
        with self._lock:
            entry = self._rate_limit_state.get(key)
            if entry is None:
                return True
            if now - entry.window_start >= 60.0:
                return True
            return entry.count < limit_per_minute

    # -------------------------------------------------------------------------
    # Signal buffering
    # -------------------------------------------------------------------------

    def _buffer_signal(self, payload: _BufferedSignalInput) -> Signal:
        metadata = self._next_signal_metadata(payload.parent_signal_id_override)
        signal = Signal(
            guard_name=payload.guard_name,
            result=payload.result,
            properties=dict(payload.properties),
            timestamp_ms=int(time.time() * 1000),
            signal_id=metadata["signal_id"],
            execution_id=metadata["execution_id"],
            parent_signal_id=metadata.get("parent_signal_id"),
            sequence_number=metadata["sequence_number"],
            callsite_id=payload.callsite_id,
            kind=payload.kind,
            dropped_signals_since_last=self._take_dropped_signals(),
            measurement=payload.measurement,
        )
        with self._lock:
            if len(self._signal_buffer) >= self._max_buffer_size():
                self._signal_buffer.pop(0)
                self._dropped_signals_pending += 1
            self._signal_buffer.append(signal)
            should_flush = len(self._signal_buffer) >= self._flush_size()

        if should_flush:
            self._flush_wakeup_event.set()
        return signal

    def flush_signals(self) -> None:
        """Immediately transmit buffered signals and unadopted guard observations."""
        with self._lock:
            if not self._signal_buffer and not self._pending_unadopted_guards:
                return
            batch = self._signal_buffer[:]
            self._signal_buffer.clear()
            unadopted_observations = [
                self._finalize_unadopted_observation(observation)
                for _, observation in sorted(self._pending_unadopted_guards.items())
            ]
            self._pending_unadopted_guards.clear()

        if batch:
            payload = json.dumps(
                {
                    "projectClientToken": self._project_client_token,
                    "environment": self._options.environment,
                    "signals": [
                        {
                            "guardName": s.guard_name,
                            "result": s.result,
                            "properties": s.properties,
                            "timestampMs": s.timestamp_ms,
                            "signalId": s.signal_id,
                            "executionId": s.execution_id,
                            **({"parentSignalId": s.parent_signal_id} if s.parent_signal_id else {}),
                            "sequenceNumber": s.sequence_number,
                            "callsiteId": s.callsite_id,
                            "kind": s.kind,
                            "droppedSignalsSinceLast": s.dropped_signals_since_last,
                            **(
                                {"measurement": self._signal_measurement_to_dict(s.measurement)}
                                if s.measurement
                                else {}
                            ),
                        }
                        for s in batch
                    ],
                }
            ).encode()

            req = urllib.request.Request(
                f"{self._backend_url().rstrip('/')}/api/v1/signals",
                data=payload,
                method="POST",
                headers={
                    "Authorization": f"Bearer {self._project_client_token}",
                    "Content-Type": "application/json",
                    **(
                        {"X-Liteguard-Environment": self._options.environment}
                        if self._options.environment
                        else {}
                    ),
                },
            )
            try:
                with urllib.request.urlopen(req, timeout=self._http_timeout_seconds()):
                    pass
            except Exception as exc:
                logger.debug("Signal flush failed: %s", exc)
                with self._lock:
                    self._signal_buffer[:0] = batch
                    max_buf = self._max_buffer_size()
                    if len(self._signal_buffer) > max_buf:
                        self._dropped_signals_pending += len(self._signal_buffer) - max_buf
                        self._signal_buffer[max_buf:] = []

        if unadopted_observations:
            payload = SendUnadoptedGuardsRequest(
                project_client_token=self._project_client_token,
                environment=self._options.environment,
                observations=unadopted_observations,
            )
            req = urllib.request.Request(
                f"{self._backend_url().rstrip('/')}/api/v1/unadopted-guards",
                data=json.dumps(
                    {
                        "projectClientToken": payload.project_client_token,
                        "environment": payload.environment,
                        "observations": [
                            {
                                "guardName": observation.guard_name,
                                "firstSeenMs": observation.first_seen_ms,
                                "lastSeenMs": observation.last_seen_ms,
                                "checkCount": observation.check_count,
                                "estimatedChecksPerMinute": observation.estimated_checks_per_minute,
                            }
                            for observation in payload.observations
                        ],
                    }
                ).encode(),
                method="POST",
                headers={
                    "Authorization": f"Bearer {self._project_client_token}",
                    "Content-Type": "application/json",
                    **(
                        {"X-Liteguard-Environment": self._options.environment}
                        if self._options.environment
                        else {}
                    ),
                },
            )
            try:
                with urllib.request.urlopen(req, timeout=self._http_timeout_seconds()):
                    pass
            except Exception as exc:
                logger.debug("Unadopted guard flush failed: %s", exc)
                with self._lock:
                    for observation in unadopted_observations:
                        self._merge_pending_unadopted_observation(observation)

    def _finalize_unadopted_observation(
        self,
        observation: _PendingUnadoptedGuardObservation,
    ) -> UnadoptedGuardObservation:
        return UnadoptedGuardObservation(
            guard_name=observation.guard_name,
            first_seen_ms=observation.first_seen_ms,
            last_seen_ms=observation.last_seen_ms,
            check_count=observation.check_count,
            estimated_checks_per_minute=_estimate_checks_per_minute(
                observation.first_seen_ms,
                observation.last_seen_ms,
                observation.check_count,
            ),
        )

    def _merge_pending_unadopted_observation(self, observation: UnadoptedGuardObservation) -> None:
        existing = self._pending_unadopted_guards.get(observation.guard_name)
        if existing is None:
            self._pending_unadopted_guards[observation.guard_name] = _PendingUnadoptedGuardObservation(
                guard_name=observation.guard_name,
                first_seen_ms=observation.first_seen_ms,
                last_seen_ms=observation.last_seen_ms,
                check_count=observation.check_count,
            )
            return

        existing.first_seen_ms = min(existing.first_seen_ms, observation.first_seen_ms)
        existing.last_seen_ms = max(existing.last_seen_ms, observation.last_seen_ms)
        existing.check_count += observation.check_count

    def _max_buffer_size(self) -> int:
        return self._flush_size() * self._flush_buffer_multiplier()

    def _take_dropped_signals(self) -> int:
        with self._lock:
            dropped = self._dropped_signals_pending
            self._dropped_signals_pending = 0
            return dropped

    def _next_signal_metadata(self, parent_signal_id_override: str | None = None) -> dict[str, str | int]:
        state = _execution_state.get()
        signal_id = _next_signal_id()
        if state is None:
            return {
                "signal_id": signal_id,
                "execution_id": _next_signal_id(),
                "sequence_number": 1,
            }

        state.sequence_number += 1
        parent_signal_id = parent_signal_id_override or state.last_signal_id
        state.last_signal_id = signal_id
        data: dict[str, str | int] = {
            "signal_id": signal_id,
            "execution_id": state.execution_id,
            "sequence_number": state.sequence_number,
        }
        if parent_signal_id is not None:
            data["parent_signal_id"] = parent_signal_id
        return data

    def _capture_callsite_id(self) -> str:
        frame = inspect.currentframe()
        try:
            frame = frame.f_back if frame is not None else None
            while frame is not None:
                filename = frame.f_code.co_filename.replace("\\", "/")
                if not filename.endswith("/liteguard/client.py") and not filename.endswith("/liteguard/__init__.py"):
                    return f"{frame.f_code.co_filename}:{frame.f_lineno}"
                frame = frame.f_back
        finally:
            del frame
        return "unknown"

    def _is_measurement_enabled(self, guard: Guard, options: Options) -> bool:
        if self._options.disable_measurement:
            return False
        if options.disable_measurement:
            return False
        if guard.disable_measurement:
            return False
        return True

    def _capture_guard_check_measurement(self) -> SignalPerformance:
        rss_bytes = self._read_rss_bytes()

        gc_count = None
        try:
            gc_count = sum(stat.get("collections", 0) for stat in gc.get_stats())
        except Exception:  # pragma: no cover - best effort
            gc_count = None

        return SignalPerformance(
            guard_check=GuardCheckPerformance(
                rss_bytes=rss_bytes,
                cpu_time_ns=time.process_time_ns(),
                gc_count=gc_count,
            )
        )

    def _capture_guard_execution_measurement(
        self,
        started_at_ns: int,
        completed: bool,
        exc: Exception | None = None,
    ) -> SignalPerformance:
        rss_end_bytes = self._read_rss_bytes()

        gc_count_end = None
        try:
            gc_count_end = sum(stat.get("collections", 0) for stat in gc.get_stats())
        except Exception:  # pragma: no cover - best effort
            gc_count_end = None

        return SignalPerformance(
            guard_execution=GuardExecutionPerformance(
                duration_ns=time.perf_counter_ns() - started_at_ns,
                completed=completed,
                rss_end_bytes=rss_end_bytes,
                cpu_time_end_ns=time.process_time_ns(),
                gc_count_end=gc_count_end,
                error_class=exc.__class__.__name__ if exc is not None else None,
            )
        )

    def _signal_measurement_to_dict(self, measurement: SignalPerformance) -> dict[str, object]:
        payload: dict[str, object] = {}
        if measurement.guard_check is not None:
            payload["guardCheck"] = {
                key: value
                for key, value in {
                    "rssBytes": measurement.guard_check.rss_bytes,
                    "heapUsedBytes": measurement.guard_check.heap_used_bytes,
                    "heapTotalBytes": measurement.guard_check.heap_total_bytes,
                    "cpuTimeNs": measurement.guard_check.cpu_time_ns,
                    "gcCount": measurement.guard_check.gc_count,
                    "threadCount": measurement.guard_check.thread_count,
                }.items()
                if value is not None
            }
        if measurement.guard_execution is not None:
            payload["guardExecution"] = {
                key: value
                for key, value in {
                    "durationNs": measurement.guard_execution.duration_ns,
                    "rssEndBytes": measurement.guard_execution.rss_end_bytes,
                    "heapUsedEndBytes": measurement.guard_execution.heap_used_end_bytes,
                    "heapTotalEndBytes": measurement.guard_execution.heap_total_end_bytes,
                    "cpuTimeEndNs": measurement.guard_execution.cpu_time_end_ns,
                    "gcCountEnd": measurement.guard_execution.gc_count_end,
                    "threadCountEnd": measurement.guard_execution.thread_count_end,
                    "completed": measurement.guard_execution.completed,
                    "errorClass": measurement.guard_execution.error_class,
                }.items()
                if value is not None
            }
        return payload

    # -------------------------------------------------------------------------
    # Guard refresh
    # -------------------------------------------------------------------------

    def _fetch_guards(self) -> None:
        self._fetch_guards_for_bundle(PUBLIC_BUNDLE_KEY)

    def _fetch_guards_for_bundle(self, bundle_key: str) -> None:
        url = f"{self._backend_url().rstrip('/')}/api/v1/guards"
        headers: dict[str, str] = {
            "Authorization": f"Bearer {self._project_client_token}",
            "Content-Type": "application/json",
        }
        if self._options.environment:
            headers["X-Liteguard-Environment"] = self._options.environment

        with self._lock:
            bundle = self._bundles.get(bundle_key) or self._bundles[PUBLIC_BUNDLE_KEY]
            etag = bundle.etag
            protected_context = _clone_protected_context(bundle.protected_context)
        if etag:
            headers["If-None-Match"] = etag

        payload = GetGuardsRequest(
            project_client_token=self._project_client_token,
            environment=self._options.environment,
            protected_context=protected_context,
        )

        req = urllib.request.Request(
            url,
            data=json.dumps(
                {
                    "projectClientToken": payload.project_client_token,
                    "environment": payload.environment,
                    **(
                        {
                            "protectedContext": {
                                "properties": payload.protected_context.properties,
                                "signature": payload.protected_context.signature,
                            }
                        }
                        if payload.protected_context is not None
                        else {}
                    ),
                }
            ).encode(),
            headers=headers,
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=self._http_timeout_seconds()) as resp:
                body: dict[str, Any] = json.loads(resp.read())
        except urllib.error.HTTPError as exc:
            if exc.code == 304:
                return
            self._log("warning", "Guard fetch failed: %s", exc)
            return
        except Exception as exc:
            self._log("warning", "Guard fetch error: %s", exc)
            return

        try:
            server_refresh_rate = int(body.get("refreshRateSeconds", self._configured_refresh_rate_seconds()))
            effective_refresh_rate = max(
                self._configured_refresh_rate_seconds(),
                server_refresh_rate if server_refresh_rate > 0 else self._configured_refresh_rate_seconds(),
            )
            guards = {guard.name: guard for guard in _parse_guards(body.get("guards", []))}
        except Exception as exc:
            self._log("warning", "Guard payload invalid: %s", exc)
            return

        with self._lock:
            previous_refresh_rate = self._current_refresh_rate_seconds
            next_bundle = self._bundles.get(bundle_key) or self._create_empty_bundle(bundle_key, protected_context)
            next_bundle.guards = guards
            next_bundle.ready = True
            next_bundle.etag = body.get("etag", "")
            next_bundle.protected_context = _clone_protected_context(protected_context)
            next_bundle.refresh_rate_seconds = effective_refresh_rate
            self._bundles[bundle_key] = next_bundle
            self._recompute_refresh_interval_locked()
            refresh_rate_changed = self._current_refresh_rate_seconds != previous_refresh_rate

        if refresh_rate_changed:
            self._refresh_wakeup_event.set()

    def _create_empty_bundle(
        self,
        bundle_key: str,
        protected_context: ProtectedContext | None,
    ) -> _GuardBundle:
        return _GuardBundle(
            key=bundle_key,
            guards={},
            ready=False,
            etag="",
            protected_context=_clone_protected_context(protected_context),
            refresh_rate_seconds=self._configured_refresh_rate_seconds(),
        )

    def _bundle_for_scope(self, scope: LiteguardScope) -> _GuardBundle:
        return self._bundles.get(scope._get_bundle_key()) or self._bundles[PUBLIC_BUNDLE_KEY]

    def _ensure_bundle_for_protected_context(self, protected_context: ProtectedContext | None) -> str:
        bundle_key = _protected_context_cache_key(protected_context)
        with self._lock:
            existing = self._bundles.get(bundle_key)
            if existing is not None and existing.ready:
                return bundle_key
            if existing is None:
                self._bundles[bundle_key] = self._create_empty_bundle(bundle_key, protected_context)

        self._fetch_guards_for_bundle(bundle_key)
        return bundle_key

    def _ensure_public_bundle_ready(self) -> None:
        with self._lock:
            public_bundle = self._bundles[PUBLIC_BUNDLE_KEY]
            if public_bundle.ready:
                return
        self._fetch_guards_for_bundle(PUBLIC_BUNDLE_KEY)

    def _recompute_refresh_interval_locked(self) -> None:
        next_refresh_rate = 0
        for bundle in self._bundles.values():
            if bundle.refresh_rate_seconds <= 0:
                continue
            if next_refresh_rate == 0 or bundle.refresh_rate_seconds < next_refresh_rate:
                next_refresh_rate = bundle.refresh_rate_seconds
        if next_refresh_rate <= 0:
            next_refresh_rate = self._configured_refresh_rate_seconds()
        if next_refresh_rate <= 0:
            next_refresh_rate = DEFAULT_REFRESH_RATE_SECONDS
        self._current_refresh_rate_seconds = next_refresh_rate

    # -------------------------------------------------------------------------
    # Background loops
    # -------------------------------------------------------------------------

    def _refresh_loop(self) -> None:
        while not self._stop_event.is_set():
            with self._lock:
                refresh_rate_seconds = self._current_refresh_rate_seconds

            woke_early = self._refresh_wakeup_event.wait(refresh_rate_seconds)
            self._refresh_wakeup_event.clear()
            if self._stop_event.is_set():
                break
            if woke_early:
                continue

            with self._lock:
                bundle_keys = sorted(self._bundles)
            for bundle_key in bundle_keys:
                self._fetch_guards_for_bundle(bundle_key)

    def _flush_loop(self) -> None:
        while not self._stop_event.is_set():
            self._flush_wakeup_event.wait(self._configured_flush_rate_seconds())
            self._flush_wakeup_event.clear()
            if self._stop_event.is_set():
                break
            self.flush_signals()

    def _read_rss_bytes(self) -> int | None:
        if resource is None:
            return None

        try:
            raw_rss = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
        except Exception:  # pragma: no cover - best effort
            return None

        if raw_rss < 0:
            return None
        if sys.platform == "darwin":
            return raw_rss
        if sys.platform.startswith("linux"):
            return raw_rss * 1024
        return None

    # -------------------------------------------------------------------------
    # Scope internals
    # -------------------------------------------------------------------------

    def _derive_scope(
        self,
        scope: LiteguardScope,
        *,
        properties: Properties | None = None,
        protected_bundle_key: str | None = None,
        protected_context: ProtectedContext | None | object = _KEEP,
    ) -> LiteguardScope:
        resolved = self._resolve_scope(scope)
        next_properties = properties if properties is not None else resolved.get_properties()
        next_protected_context = (
            resolved.get_protected_context() if protected_context is _KEEP else protected_context
        )
        return LiteguardScope(
            self,
            next_properties,
            protected_bundle_key=(
                resolved._get_bundle_key() if protected_bundle_key is None else protected_bundle_key
            ),
            protected_context=next_protected_context,
        )

    def _bind_protected_context_to_scope(
        self,
        scope: LiteguardScope,
        protected_context: ProtectedContext,
    ) -> LiteguardScope:
        resolved = self._resolve_scope(scope)
        cloned = _clone_protected_context(protected_context)
        bundle_key = self._ensure_bundle_for_protected_context(cloned)
        return LiteguardScope(
            self,
            resolved.get_properties(),
            protected_bundle_key=bundle_key,
            protected_context=cloned,
        )

    def _replace_current_scope(self, next_scope: LiteguardScope) -> LiteguardScope:
        resolved = self._resolve_scope(next_scope)
        current = self._request_scope.get()
        if current is not None and current._belongs_to(self):
            self._request_scope.set(resolved)
            return resolved

        with self._lock:
            self._default_scope = resolved
        return resolved

    def _resolve_scope(self, scope: LiteguardScope) -> LiteguardScope:
        if not scope._belongs_to(self):
            raise ValueError("[liteguard] Scope belongs to a different Liteguard client.")
        return scope

    # -------------------------------------------------------------------------
    # Test helpers
    # -------------------------------------------------------------------------

    def _log(self, level: str, message: str, *args: object) -> None:
        if not self._options.quiet:
            getattr(logger, level)(message, *args)

    def _http_timeout_seconds(self) -> int:
        timeout = self._options.http_timeout_seconds
        return timeout if timeout > 0 else DEFAULT_HTTP_TIMEOUT_SECONDS

    def _backend_url(self) -> str:
        backend_url = self._options.backend_url.strip()
        return backend_url if backend_url else DEFAULT_BACKEND_URL

    def _flush_size(self) -> int:
        flush_size = self._options.flush_size
        return flush_size if flush_size > 0 else DEFAULT_FLUSH_SIZE

    def _flush_buffer_multiplier(self) -> int:
        multiplier = self._options.flush_buffer_multiplier
        return multiplier if multiplier > 0 else DEFAULT_FLUSH_BUFFER_MULTIPLIER

    def _configured_flush_rate_seconds(self) -> int:
        flush_rate = self._options.flush_rate_seconds
        return flush_rate if flush_rate > 0 else DEFAULT_FLUSH_RATE_SECONDS

    def _configured_refresh_rate_seconds(self) -> int:
        refresh_rate = self._options.refresh_rate_seconds
        return refresh_rate if refresh_rate > 0 else DEFAULT_REFRESH_RATE_SECONDS

    def _reset_rate_limit_state(self, name: str | None = None) -> None:
        with self._lock:
            if name is not None:
                self._rate_limit_state.pop(name, None)
                prefix = name + "\x00"
                for key in list(self._rate_limit_state):
                    if key.startswith(prefix):
                        del self._rate_limit_state[key]
            else:
                self._rate_limit_state.clear()

    def _set_guards(self, guards: list[Guard]) -> None:
        with self._lock:
            bundle = self._bundles.get(PUBLIC_BUNDLE_KEY) or self._create_empty_bundle(PUBLIC_BUNDLE_KEY, None)
            bundle.guards = {guard.name: guard for guard in guards}
            bundle.ready = True
            bundle.etag = ""
            bundle.protected_context = None
            bundle.refresh_rate_seconds = self._configured_refresh_rate_seconds()
            self._bundles[PUBLIC_BUNDLE_KEY] = bundle
            self._recompute_refresh_interval_locked()

    def _set_protected_guards(
        self,
        protected_context: ProtectedContext,
        guards: list[Guard],
    ) -> None:
        bundle_key = _protected_context_cache_key(protected_context)
        with self._lock:
            bundle = self._bundles.get(bundle_key) or self._create_empty_bundle(bundle_key, protected_context)
            bundle.guards = {guard.name: guard for guard in guards}
            bundle.ready = True
            bundle.etag = ""
            bundle.protected_context = _clone_protected_context(protected_context)
            bundle.refresh_rate_seconds = self._configured_refresh_rate_seconds()
            self._bundles[bundle_key] = bundle
            self._recompute_refresh_interval_locked()

    def _get_protected_context(self) -> ProtectedContext | None:
        return self.get_active_scope().get_protected_context()

    def _get_pending_unadopted_guards(self) -> list[UnadoptedGuardObservation]:
        with self._lock:
            return [
                self._finalize_unadopted_observation(observation)
                for _, observation in sorted(self._pending_unadopted_guards.items())
            ]

    def _get_refresh_rate_seconds(self) -> int:
        with self._lock:
            return self._current_refresh_rate_seconds


def _next_signal_id() -> str:
    return f"{time.time_ns():x}-{next(_signal_counter):x}"


def _estimate_checks_per_minute(first_seen_ms: int, last_seen_ms: int, check_count: int) -> float:
    if check_count <= 0:
        return 0.0
    window_ms = max(1000, last_seen_ms - first_seen_ms)
    return (check_count * 60000) / window_ms


# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------

def _parse_guards(raw: list[dict[str, Any]]) -> list[Guard]:
    return [_parse_guard(guard) for guard in raw]


def _parse_guard(raw: dict[str, Any]) -> Guard:
    return Guard(
        name=raw["name"],
        rules=[_parse_rule(rule) for rule in raw.get("rules", [])],
        default_value=bool(raw.get("defaultValue", False)),
        adopted=bool(raw.get("adopted", False)),
        rate_limit_per_minute=int(raw.get("rateLimitPerMinute", 0)),
        rate_limit_properties=list(raw.get("rateLimitProperties", [])),
        disable_measurement=bool(raw.get("disableMeasurement", False)),
    )


def _parse_rule(raw: dict[str, Any]) -> Rule:
    return Rule(
        property_name=raw["propertyName"],
        operator=Operator(raw["operator"]),
        values=raw.get("values", []),
        result=bool(raw.get("result", False)),
        enabled=bool(raw.get("enabled", True)),
    )
