"""AUTO-GENERATED by scripts/generate_sync.py - DO NOT EDIT.

Source: limiter.py

This module provides synchronous versions of the async classes.
Changes should be made to the source file, then regenerated.
"""

import logging
import time
import warnings
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal

from .limiter import OnUnavailable as OnUnavailable

if TYPE_CHECKING:
    from .sync_repository_protocol import SpeculativeResult, SyncRepositoryProtocol
from .bucket import (
    build_limit_status,
    calculate_available,
    calculate_time_until_available,
    try_consume,
    would_refill_satisfy,
)
from .exceptions import RateLimiterUnavailable, RateLimitExceeded, ValidationError
from .models import (
    AuditEvent,
    BucketState,
    Entity,
    EntityCapacity,
    Limit,
    LimiterInfo,
    LimitStatus,
    OnUnavailableAction,
    ResourceCapacity,
    StackOptions,
    UsageSnapshot,
    UsageSummary,
    validate_identifier,
    validate_resource,
)
from .schema import DEFAULT_RESOURCE
from .sync_config_cache import ConfigSource
from .sync_lease import LeaseEntry, SyncLease
from .sync_repository import SyncRepository
from .sync_repository_protocol import SpeculativeFailureReason

_UNSET: Any = object()
logger = logging.getLogger(__name__)


class SyncRateLimiter:
    """
    Async rate limiter backed by DynamoDB.

    Implements token bucket algorithm with support for:
    - Multiple limits per entity/resource
    - Two-level hierarchy (parent/child entities)
    - Cascade mode (consume from entity + parent)
    - Stored limit configs
    - Usage analytics

    Example (new API - preferred):
        from zae_limiter import SyncRateLimiter, SyncRepository, StackOptions

        repo = SyncRepository(
            name="my-app",
            region="us-east-1",
            stack_options=StackOptions(),
        )
        limiter = SyncRateLimiter(repository=repo)

    Example (old API - deprecated):
        limiter = SyncRateLimiter(
            name="my-app",
            region="us-east-1",
            stack_options=StackOptions(),
        )
    """

    def __init__(
        self,
        repository: "SyncRepositoryProtocol | None" = None,
        name: str | None = None,
        region: str | None = None,
        endpoint_url: str | None = None,
        stack_options: StackOptions | None = None,
        on_unavailable: "OnUnavailable | Any" = _UNSET,
        auto_update: "bool | Any" = _UNSET,
        bucket_ttl_refill_multiplier: "int | Any" = _UNSET,
        speculative_writes: bool = True,
    ) -> None:
        """
        Initialize the rate limiter.

        Args:
            repository: SyncRepository instance (new API, preferred).
                Pass a SyncRepository or any SyncRepositoryProtocol implementation.
            name: DEPRECATED. Use ``SyncRepository(name=...)`` instead.
            region: DEPRECATED. Use ``SyncRepository(region=...)`` instead.
            endpoint_url: DEPRECATED. Use ``SyncRepository(endpoint_url=...)`` instead.
            stack_options: DEPRECATED. Use ``SyncRepository(stack_options=...)`` instead.
            on_unavailable: DEPRECATED. Use ``set_system_defaults(on_unavailable=...)``
                or pass ``on_unavailable=`` to ``acquire()`` instead.
            auto_update: DEPRECATED. Use ``SyncRepository.builder(...).auto_update().build()``
                instead.
            bucket_ttl_refill_multiplier: DEPRECATED. Use
                ``SyncRepository.builder(...).bucket_ttl_multiplier().build()`` instead.
            speculative_writes: Enable speculative UpdateItem fast path.
                When True, acquire() tries a speculative write first, falling
                back to the full read-write path only when needed.

        Raises:
            ValueError: If both repository and name/region/endpoint_url/stack_options
                are provided.
        """
        from .naming import normalize_name

        if on_unavailable is not _UNSET:
            warnings.warn(
                "on_unavailable constructor parameter is deprecated. Use set_system_defaults(on_unavailable=...) or acquire(on_unavailable=...) instead. This will be removed in v1.0.0.",
                DeprecationWarning,
                stacklevel=2,
            )
        if auto_update is not _UNSET:
            warnings.warn(
                "auto_update constructor parameter is deprecated. Use SyncRepository.builder(...).auto_update(True).build() instead. This will be removed in v1.0.0.",
                DeprecationWarning,
                stacklevel=2,
            )
        if bucket_ttl_refill_multiplier is not _UNSET:
            warnings.warn(
                "bucket_ttl_refill_multiplier constructor parameter is deprecated. Use SyncRepository.builder(...).bucket_ttl_multiplier(7).build() instead. This will be removed in v1.0.0.",
                DeprecationWarning,
                stacklevel=2,
            )
        old_params_provided = any(
            p is not None for p in (name, region, endpoint_url, stack_options)
        )
        if repository is not None and old_params_provided:
            raise ValueError(
                "Cannot specify both 'repository' and 'name'/'region'/'endpoint_url'/'stack_options'. Use SyncRepository(...) to configure data access."
            )
        if repository is not None:
            self._repository = repository
        elif old_params_provided:
            warnings.warn(
                "Passing name/region/endpoint_url/stack_options directly to SyncRateLimiter is deprecated. Use SyncRepository(...) instead. This will be removed in v1.0.0.",
                DeprecationWarning,
                stacklevel=2,
            )
            effective_name = name if name is not None else "limiter"
            self._repository = SyncRepository(
                name=normalize_name(effective_name),
                region=region,
                endpoint_url=endpoint_url,
                stack_options=stack_options,
            )
        else:
            warnings.warn(
                "SyncRateLimiter() without a repository argument is deprecated. Use SyncRateLimiter(repository=SyncRepository(...)) instead. This will be removed in v1.0.0.",
                DeprecationWarning,
                stacklevel=2,
            )
            self._repository = SyncRepository(name=normalize_name("limiter"))
        if repository is None:
            assert isinstance(self._repository, SyncRepository)
            repo = self._repository
            if bucket_ttl_refill_multiplier is not _UNSET:
                repo._bucket_ttl_refill_multiplier = bucket_ttl_refill_multiplier
            if on_unavailable is not _UNSET:
                repo._on_unavailable_cache = on_unavailable.value
        self._initialized = False
        self._speculative_writes = speculative_writes

    @property
    def name(self) -> str:
        """DEPRECATED. Use ``repository.stack_name`` instead."""
        warnings.warn(
            "SyncRateLimiter.name is deprecated. Use repository.stack_name instead. This will be removed in v1.0.0.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self._repository.stack_name

    @property
    def stack_name(self) -> str:
        """DEPRECATED. Use ``repository.stack_name`` instead."""
        warnings.warn(
            "SyncRateLimiter.stack_name is deprecated. Use repository.stack_name instead. This will be removed in v1.0.0.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self._repository.stack_name

    @property
    def table_name(self) -> str:
        """DEPRECATED. Use ``repository.stack_name`` instead."""
        warnings.warn(
            "SyncRateLimiter.table_name is deprecated. Use repository.stack_name instead. This will be removed in v1.0.0.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self._repository.stack_name

    @staticmethod
    def _datetime_to_iso(dt: datetime) -> str:
        """Convert datetime to ISO 8601 UTC string.

        Handles both timezone-aware and naive datetimes:
        - Timezone-aware: Converted to UTC, formatted as ISO 8601
        - Naive: Assumed to be UTC, formatted with 'Z' suffix

        Args:
            dt: Datetime to convert

        Returns:
            ISO 8601 formatted UTC timestamp (e.g., "2024-01-01T14:00:00Z")
        """
        from datetime import UTC

        if dt.tzinfo is not None:
            utc_dt = dt.astimezone(UTC)
            return utc_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
        else:
            return dt.strftime("%Y-%m-%dT%H:%M:%SZ")

    @classmethod
    def list_deployed(
        cls, region: str | None = None, endpoint_url: str | None = None
    ) -> list[LimiterInfo]:
        """
        List all deployed rate limiter instances in a region.

        This is a class method that discovers existing deployments without
        requiring an initialized SyncRateLimiter instance. It queries CloudFormation
        for stacks tagged with ``ManagedBy=zae-limiter``.

        Args:
            region: AWS region (default: use boto3 defaults)
            endpoint_url: CloudFormation endpoint (for LocalStack)

        Returns:
            List of LimiterInfo objects describing deployed instances.
            Sorted by user-friendly name. Excludes deleted stacks.

        Example:
            # Discover all limiters in us-east-1
            limiters = SyncRateLimiter.list_deployed(region="us-east-1")
            for limiter in limiters:
                if limiter.is_healthy:
                    print(f"✓ {limiter.user_name}: {limiter.version}")
                elif limiter.is_failed:
                    print(f"✗ {limiter.user_name}: {limiter.stack_status}")

        Raises:
            ClientError: If CloudFormation API call fails
        """
        from .infra.sync_discovery import SyncInfrastructureDiscovery

        with SyncInfrastructureDiscovery(region=region, endpoint_url=endpoint_url) as discovery:
            return discovery.list_limiters()

    def _ensure_initialized(self) -> None:
        """Ensure infrastructure exists."""
        if self._initialized:
            return
        if getattr(self._repository, "_builder_initialized", False):
            self._initialized = True
            return
        self._repository.ensure_infrastructure()
        self._initialized = True

    def close(self) -> None:
        """Close the underlying connections."""
        self._repository.close()

    def __enter__(self) -> "SyncRateLimiter":
        self._ensure_initialized()
        return self

    def __exit__(self, *args: Any) -> None:
        self.close()

    def is_available(self, timeout: float = 1.0) -> bool:
        """
        Check if the rate limiter backend (DynamoDB) is reachable.

        Performs a lightweight health check without requiring initialization.
        This method never raises exceptions - it returns False on any error.

        Args:
            timeout: Maximum time in seconds to wait for response (default: 1.0)

        Returns:
            True if DynamoDB table is reachable, False otherwise.

        Example:
            limiter = SyncRateLimiter(name="my-app", region="us-east-1")
            if limiter.is_available():
                async with limiter.acquire(...) as lease:
                    ...
            else:
                # Handle degraded mode
                pass
        """
        try:
            return self._repository.ping()
        except (TimeoutError, Exception):
            return False

    def create_entity(
        self,
        entity_id: str,
        name: str | None = None,
        parent_id: str | None = None,
        cascade: bool = False,
        metadata: dict[str, str] | None = None,
        principal: str | None = None,
    ) -> Entity:
        """
        Create a new entity.

        Args:
            entity_id: Unique identifier for the entity
            name: Human-readable name (defaults to entity_id)
            parent_id: Parent entity ID (None for root/project entities)
            cascade: If True, acquire() will also consume from parent entity
            metadata: Additional metadata to store
            principal: Caller identity for audit logging (optional)

        Returns:
            The created Entity

        Raises:
            EntityExistsError: If entity already exists
        """
        self._ensure_initialized()
        return self._repository.create_entity(
            entity_id=entity_id,
            name=name,
            parent_id=parent_id,
            cascade=cascade,
            metadata=metadata,
            principal=principal,
        )

    def get_entity(self, entity_id: str) -> Entity | None:
        """Get an entity by ID."""
        self._ensure_initialized()
        return self._repository.get_entity(entity_id)

    def delete_entity(self, entity_id: str, principal: str | None = None) -> None:
        """
        Delete an entity and all its related data.

        Args:
            entity_id: ID of the entity to delete
            principal: Caller identity for audit logging (optional)
        """
        self._ensure_initialized()
        self._repository.delete_entity(entity_id, principal=principal)

    def get_children(self, parent_id: str) -> list[Entity]:
        """Get all children of a parent entity."""
        self._ensure_initialized()
        return self._repository.get_children(parent_id)

    def get_audit_events(
        self, entity_id: str, limit: int = 100, start_event_id: str | None = None
    ) -> list[AuditEvent]:
        """
        Get audit events for an entity.

        Retrieves security audit events logged for administrative operations
        on the specified entity, ordered by most recent first.

        Args:
            entity_id: ID of the entity to query
            limit: Maximum number of events to return (default: 100)
            start_event_id: Event ID to start after (for pagination)

        Returns:
            List of AuditEvent objects, ordered by most recent first

        Example:
            events = limiter.get_audit_events("proj-1")
            for event in events:
                print(f"{event.timestamp}: {event.action} by {event.principal}")
        """
        self._ensure_initialized()
        return self._repository.get_audit_events(
            entity_id=entity_id, limit=limit, start_event_id=start_event_id
        )

    def get_usage_snapshots(
        self,
        entity_id: str | None = None,
        resource: str | None = None,
        window_type: str | None = None,
        start_time: datetime | None = None,
        end_time: datetime | None = None,
        limit: int = 100,
        next_key: dict[str, Any] | None = None,
    ) -> tuple[list[UsageSnapshot], dict[str, Any] | None]:
        """
        Query usage snapshots for historical consumption data.

        Usage snapshots are created by the aggregator Lambda from DynamoDB
        stream events. They track token consumption per entity/resource
        within time windows (hourly, daily).

        Supports two query modes:
        1. Entity-scoped: Provide entity_id (optionally with resource filter)
        2. Resource-scoped: Provide resource to query across all entities

        Args:
            entity_id: Entity to query (uses primary key)
            resource: Resource name filter (required if entity_id is None)
            window_type: Filter by window type ("hourly", "daily")
            start_time: Filter snapshots >= this timestamp
            end_time: Filter snapshots <= this timestamp
            limit: Maximum items to fetch from DynamoDB per page (default: 100)
            next_key: Pagination cursor from previous call

        Returns:
            Tuple of (snapshots, next_key). next_key is None if no more results.

        Raises:
            ValueError: If neither entity_id nor resource is provided

        Note:
            The ``limit`` parameter controls the DynamoDB query batch size.
            Client-side filters (window_type, start_time, end_time) are applied
            after fetching, so the returned count may be less than ``limit``.
            Use ``next_key`` to paginate through all matching results.

        Example:
            # Get hourly snapshots for an entity
            snapshots, cursor = limiter.get_usage_snapshots(
                entity_id="user-123",
                resource="gpt-4",
                window_type="hourly",
                start_time=datetime(2024, 1, 1),
                end_time=datetime(2024, 1, 31),
            )
            for snap in snapshots:
                print(f"{snap.window_start}: {snap.counters}")

            # Paginate through results
            while cursor:
                more, cursor = limiter.get_usage_snapshots(
                    entity_id="user-123",
                    next_key=cursor,
                )
        """
        self._ensure_initialized()
        start_str = self._datetime_to_iso(start_time) if start_time else None
        end_str = self._datetime_to_iso(end_time) if end_time else None
        return self._repository.get_usage_snapshots(
            entity_id=entity_id,
            resource=resource,
            window_type=window_type,
            start_time=start_str,
            end_time=end_str,
            limit=limit,
            next_key=next_key,
        )

    def get_usage_summary(
        self,
        entity_id: str | None = None,
        resource: str | None = None,
        window_type: str | None = None,
        start_time: datetime | None = None,
        end_time: datetime | None = None,
    ) -> UsageSummary:
        """
        Get aggregated usage summary across multiple snapshots.

        Fetches all matching snapshots and computes total and average
        consumption statistics. Useful for billing, reporting, and
        capacity planning.

        Args:
            entity_id: Entity to query
            resource: Resource name filter (required if entity_id is None)
            window_type: Filter by window type ("hourly", "daily")
            start_time: Filter snapshots >= this timestamp
            end_time: Filter snapshots <= this timestamp

        Returns:
            UsageSummary with total and average consumption per limit type

        Raises:
            ValueError: If neither entity_id nor resource is provided

        Example:
            summary = limiter.get_usage_summary(
                entity_id="user-123",
                resource="gpt-4",
                window_type="hourly",
                start_time=datetime(2024, 1, 1),
                end_time=datetime(2024, 1, 31),
            )
            print(f"Total tokens: {summary.total.get('tpm', 0)}")
            print(f"Average per hour: {summary.average.get('tpm', 0.0):.1f}")
            print(f"Snapshots: {summary.snapshot_count}")
        """
        self._ensure_initialized()
        start_str = self._datetime_to_iso(start_time) if start_time else None
        end_str = self._datetime_to_iso(end_time) if end_time else None
        return self._repository.get_usage_summary(
            entity_id=entity_id,
            resource=resource,
            window_type=window_type,
            start_time=start_str,
            end_time=end_str,
        )

    @contextmanager
    def acquire(
        self,
        entity_id: str,
        resource: str,
        consume: dict[str, int],
        limits: list[Limit] | None = None,
        use_stored_limits: bool = False,
        on_unavailable: OnUnavailable | None = None,
    ) -> Iterator[SyncLease]:
        """
        Acquire rate limit capacity.

        Limits are resolved automatically from stored config using four-tier
        hierarchy: Entity > Entity Default > Resource > System. Pass ``limits`` to override.

        Cascade behavior is controlled by the entity's ``cascade`` flag, set at
        entity creation time via ``create_entity(cascade=True)``. When enabled,
        acquire() automatically consumes from both the entity and its parent.

        Args:
            entity_id: Entity to acquire capacity for
            resource: Resource being accessed (e.g., "gpt-4")
            consume: Amounts to consume by limit name
            limits: Override stored config with explicit limits (optional)
            use_stored_limits: DEPRECATED - limits are now always resolved from
                stored config. This parameter will be removed in v1.0.
            on_unavailable: Override default on_unavailable behavior

        Yields:
            SyncLease for managing additional consumption

        Raises:
            RateLimitExceeded: If any limit would be exceeded
            RateLimiterUnavailable: If DynamoDB unavailable and BLOCK
            ValidationError: If no limits configured at any level
        """
        self._ensure_initialized()
        if use_stored_limits:
            warnings.warn(
                "use_stored_limits is deprecated and will be removed in v1.0. Limits are now always resolved from stored config (Entity > Resource > System). Pass limits parameter as override if needed.",
                DeprecationWarning,
                stacklevel=2,
            )
        mode = self._resolve_on_unavailable(on_unavailable)
        try:
            lease: SyncLease | None = None
            if self._speculative_writes:
                lease = self._try_speculative_acquire(
                    entity_id=entity_id, resource=resource, consume=consume
                )
            if lease is None:
                lease = self._do_acquire(
                    entity_id=entity_id, resource=resource, limits_override=limits, consume=consume
                )
        except (RateLimitExceeded, ValidationError):
            raise
        except Exception as e:
            if mode == OnUnavailable.ALLOW:
                yield SyncLease(repository=self._repository)
                return
            else:
                raise RateLimiterUnavailable(
                    str(e),
                    cause=e,
                    stack_name=self._repository.stack_name,
                    entity_id=entity_id,
                    resource=resource,
                ) from e
        lease._commit_initial()
        try:
            yield lease
            lease._commit_adjustments()
        except Exception:
            lease._rollback()
            raise

    def _try_speculative_acquire(
        self, entity_id: str, resource: str, consume: dict[str, int]
    ) -> SyncLease | None:
        """Try the speculative fast path for acquire (issue #315).

        SyncRepository checks its own entity cache (issue #318) and issues
        parallel child+parent UpdateItems when cache hit + cascade.

        Returns:
            SyncLease if speculative write succeeded (already committed).
            None if slow path is needed (refill would help, bucket missing,
            or config changed).

        Raises:
            RateLimitExceeded: If the bucket is truly exhausted (refill
                wouldn't help). Saves 1 RCU vs the slow path.
        """
        now_ms = int(time.time() * 1000)
        result = self._repository.speculative_consume(
            entity_id=entity_id, resource=resource, consume=consume
        )
        if not result.success:
            if result.parent_result is not None and result.parent_result.success:
                assert result.parent_id is not None
                self._compensate_speculative(result.parent_id, resource, consume)
            if result.failure_reason in (
                SpeculativeFailureReason.WCU_EXHAUSTED,
                SpeculativeFailureReason.BOTH_EXHAUSTED,
            ):
                self._repository.bump_shard_count(entity_id, resource, result.shard_count)
                return None
            if (
                result.shard_count > 1
                and result.failure_reason == SpeculativeFailureReason.APP_LIMIT_EXHAUSTED
            ):
                retry_result = self._retry_on_other_shard(
                    entity_id, resource, consume, ttl_seconds=None, result=result
                )
                if retry_result is not None:
                    return retry_result
            self._check_speculative_failure(result, consume, now_ms)
            return None
        entries: list[LeaseEntry] = []
        for state in result.buckets:
            amount = consume.get(state.limit_name, 0)
            if amount == 0:
                continue
            limit = Limit.from_bucket_state(state)
            entries.append(
                LeaseEntry(
                    entity_id=state.entity_id,
                    resource=state.resource,
                    limit=limit,
                    state=state,
                    consumed=amount,
                    _cascade=result.cascade,
                    _parent_id=result.parent_id,
                )
            )
        if result.parent_result is not None:
            if result.parent_result.success:
                for state in result.parent_result.buckets:
                    amount = consume.get(state.limit_name, 0)
                    if amount == 0:
                        continue
                    limit = Limit.from_bucket_state(state)
                    entries.append(
                        LeaseEntry(
                            entity_id=state.entity_id,
                            resource=state.resource,
                            limit=limit,
                            state=state,
                            consumed=amount,
                        )
                    )
            else:
                return self._handle_nested_parent_failure(
                    entity_id, resource, consume, result, now_ms
                )
        elif result.cascade and result.parent_id:
            parent_id = result.parent_id
            parent_result = self._repository.speculative_consume(
                entity_id=parent_id, resource=resource, consume=consume
            )
            if parent_result.success:
                for state in parent_result.buckets:
                    amount = consume.get(state.limit_name, 0)
                    if amount == 0:
                        continue
                    limit = Limit.from_bucket_state(state)
                    entries.append(
                        LeaseEntry(
                            entity_id=state.entity_id,
                            resource=state.resource,
                            limit=limit,
                            state=state,
                            consumed=amount,
                        )
                    )
            else:
                if parent_result.old_buckets is None:
                    self._compensate_child(entity_id, resource, consume)
                    return None
                parent_names = {b.limit_name for b in parent_result.old_buckets}
                if not all(name in parent_names for name in consume):
                    self._compensate_child(entity_id, resource, consume)
                    return None
                would_help, parent_statuses = would_refill_satisfy(
                    parent_result.old_buckets, consume, now_ms
                )
                if not would_help:
                    self._compensate_child(entity_id, resource, consume)
                    child_statuses = [
                        build_limit_status(
                            entity_id=s.entity_id,
                            resource=s.resource,
                            limit=Limit.from_bucket_state(s),
                            state=s,
                            requested=consume.get(s.limit_name, 0),
                            now_ms=now_ms,
                        )
                        for s in result.buckets
                    ]
                    raise RateLimitExceeded(child_statuses + parent_statuses)
                try:
                    parent_lease = self._try_parent_only_acquire(
                        parent_id, resource, consume, entries
                    )
                except Exception:
                    self._compensate_child(entity_id, resource, consume)
                    raise
                if parent_lease is not None:
                    return parent_lease
                self._compensate_child(entity_id, resource, consume)
                return None
        lease = SyncLease(repository=self._repository, entries=entries)
        lease._initial_committed = True
        for entry in entries:
            entry._initial_consumed = entry.consumed
        return lease

    def _handle_nested_parent_failure(
        self,
        entity_id: str,
        resource: str,
        consume: dict[str, int],
        result: "SpeculativeResult",
        now_ms: int,
    ) -> SyncLease | None:
        """Handle parent failure from nested SpeculativeResult (issue #318).

        Child succeeded speculatively (result.success=True).
        Parent failed (result.parent_result.success=False).
        Decides whether to compensate child and fall back, try parent-only
        slow path, or fast-reject.

        Returns:
            SyncLease if parent-only slow path succeeded.
            None if full slow path is needed.

        Raises:
            RateLimitExceeded: If parent is truly exhausted.
        """
        assert result.parent_result is not None
        assert result.parent_id is not None
        parent_result = result.parent_result
        parent_id = result.parent_id
        if parent_result.old_buckets is None:
            self._compensate_child(entity_id, resource, consume)
            return None
        parent_names = {b.limit_name for b in parent_result.old_buckets}
        if not all(name in parent_names for name in consume):
            self._compensate_child(entity_id, resource, consume)
            return None
        would_help, parent_statuses = would_refill_satisfy(
            parent_result.old_buckets, consume, now_ms
        )
        if not would_help:
            self._compensate_child(entity_id, resource, consume)
            child_statuses = [
                build_limit_status(
                    entity_id=s.entity_id,
                    resource=s.resource,
                    limit=Limit.from_bucket_state(s),
                    state=s,
                    requested=consume.get(s.limit_name, 0),
                    now_ms=now_ms,
                )
                for s in result.buckets
            ]
            raise RateLimitExceeded(child_statuses + parent_statuses)
        entries: list[LeaseEntry] = []
        for state in result.buckets:
            amount = consume.get(state.limit_name, 0)
            if amount == 0:
                continue
            limit = Limit.from_bucket_state(state)
            entries.append(
                LeaseEntry(
                    entity_id=state.entity_id,
                    resource=state.resource,
                    limit=limit,
                    state=state,
                    consumed=amount,
                    _cascade=result.cascade,
                    _parent_id=result.parent_id,
                )
            )
        try:
            parent_lease = self._try_parent_only_acquire(parent_id, resource, consume, entries)
        except Exception:
            self._compensate_child(entity_id, resource, consume)
            raise
        if parent_lease is not None:
            return parent_lease
        self._compensate_child(entity_id, resource, consume)
        return None

    def _compensate_child(self, entity_id: str, resource: str, consume: dict[str, int]) -> None:
        """Compensate a speculatively consumed child by adding tokens back."""
        self._compensate_speculative(entity_id, resource, consume)

    def _compensate_speculative(
        self, entity_id: str, resource: str, consume: dict[str, int]
    ) -> None:
        """Compensate a speculative write by adding consumed tokens back."""
        deltas = {name: -(amount * 1000) for name, amount in consume.items()}
        compensate_item = self._repository.build_composite_adjust(
            entity_id=entity_id, resource=resource, deltas=deltas
        )
        self._repository.write_each([compensate_item])

    @staticmethod
    def _check_speculative_failure(
        result: "SpeculativeResult", consume: dict[str, int], now_ms: int
    ) -> None:
        """Check a failed speculative result and raise if truly exhausted.

        Raises RateLimitExceeded if refill won't help (fast rejection).
        Returns normally if slow path should be attempted.
        """
        if result.old_buckets is None:
            return
        bucket_names = {b.limit_name for b in result.old_buckets}
        if not all(name in bucket_names for name in consume):
            return
        would_help, statuses = would_refill_satisfy(result.old_buckets, consume, now_ms)
        if not would_help:
            raise RateLimitExceeded(statuses)

    _MAX_SHARD_RETRIES = 2

    def _retry_on_other_shard(
        self,
        entity_id: str,
        resource: str,
        consume: dict[str, int],
        ttl_seconds: int | None,
        result: "SpeculativeResult",
    ) -> "SyncLease | None":
        """Retry speculative consume on untried shards (GHSA-76rv shard retry).

        When application limits are exhausted on one shard, other shards may
        still have available tokens (since capacity is divided across shards).
        This method picks random untried shards up to ``_MAX_SHARD_RETRIES``.

        Args:
            entity_id: Entity owning the bucket
            resource: Resource name
            consume: Amount per limit (tokens, not milli)
            ttl_seconds: TTL in seconds from now, or None for no TTL change
            result: The failed SpeculativeResult from the initial shard

        Returns:
            SyncLease if a retry on another shard succeeded, None if all retries
            failed or no untried shards remain.
        """
        import random

        tried_shards = {result.shard_id}
        shard_count = result.shard_count
        for _ in range(self._MAX_SHARD_RETRIES):
            untried = [s for s in range(shard_count) if s not in tried_shards]
            if not untried:
                break
            new_shard = random.choice(untried)
            tried_shards.add(new_shard)
            retry = self._repository.speculative_consume(
                entity_id, resource, consume, ttl_seconds, shard_id=new_shard
            )
            if retry.success:
                return self._build_lease_from_speculative(entity_id, resource, consume, retry)
        return None

    def _build_lease_from_speculative(
        self, entity_id: str, resource: str, consume: dict[str, int], result: "SpeculativeResult"
    ) -> "SyncLease":
        """Build a pre-committed SyncLease from a successful speculative result.

        Args:
            entity_id: Entity owning the bucket
            resource: Resource name
            consume: Amount per limit that was consumed
            result: Successful SpeculativeResult with ALL_NEW buckets
        """
        entries: list[LeaseEntry] = []
        for state in result.buckets:
            amount = consume.get(state.limit_name, 0)
            if amount == 0:
                continue
            limit = Limit.from_bucket_state(state)
            entries.append(
                LeaseEntry(
                    entity_id=state.entity_id,
                    resource=state.resource,
                    limit=limit,
                    state=state,
                    consumed=amount,
                    _cascade=result.cascade,
                    _parent_id=result.parent_id,
                )
            )
        return SyncLease(entries=entries, repository=self._repository, _committed=True)

    def _try_parent_only_acquire(
        self,
        parent_id: str,
        resource: str,
        consume: dict[str, int],
        child_entries: list[LeaseEntry],
    ) -> SyncLease | None:
        """Attempt parent-only slow path after child speculative succeeded.

        Reads parent buckets, resolves limits, does refill + try_consume,
        and writes parent via single-item UpdateItem. Returns a SyncLease combining
        child's speculative entries with parent's slow-path entries.

        Returns None if parent acquire fails (caller should compensate child).
        """
        now_ms = int(time.time() * 1000)
        parent_limits, parent_config_source = self._resolve_limits(parent_id, resource, None)
        parent_buckets = self._fetch_buckets([parent_id], resource)
        parent_entries: list[LeaseEntry] = []
        statuses: list[LimitStatus] = []
        has_custom_config = parent_config_source == "entity"
        for limit in parent_limits:
            bucket_key = (parent_id, resource, limit.name)
            existing = parent_buckets.get(bucket_key)
            if existing is None:
                return None
            original_tk = existing.tokens_milli
            original_rf = existing.last_refill_ms
            amount = consume.get(limit.name, 0)
            result = try_consume(existing, amount, now_ms)
            status = LimitStatus(
                entity_id=parent_id,
                resource=resource,
                limit_name=limit.name,
                limit=limit,
                available=result.available,
                requested=amount,
                exceeded=not result.success,
                retry_after_seconds=result.retry_after_seconds,
            )
            statuses.append(status)
            if result.success:
                existing.tokens_milli = result.new_tokens_milli
                existing.last_refill_ms = result.new_last_refill_ms
                if existing.total_consumed_milli is not None and amount > 0:
                    existing.total_consumed_milli += amount * 1000
            parent_entries.append(
                LeaseEntry(
                    entity_id=parent_id,
                    resource=resource,
                    limit=limit,
                    state=existing,
                    consumed=amount if result.success else 0,
                    _original_tokens_milli=original_tk,
                    _original_rf_ms=original_rf,
                    _has_custom_config=has_custom_config,
                )
            )
        violations = [s for s in statuses if s.exceeded]
        if violations:
            return None
        all_entries = list(child_entries) + parent_entries
        lease = SyncLease(repository=self._repository, entries=all_entries)
        for entry in child_entries:
            entry._initial_consumed = entry.consumed
        parent_lease = SyncLease(repository=self._repository, entries=parent_entries)
        try:
            parent_lease._commit_initial()
        except RateLimitExceeded:
            return None
        lease._initial_committed = True
        for entry in parent_entries:
            entry._initial_consumed = entry.consumed
        return lease

    def _do_acquire(
        self,
        entity_id: str,
        resource: str,
        limits_override: list[Limit] | None,
        consume: dict[str, int],
    ) -> SyncLease:
        """Internal acquire implementation."""
        validate_identifier(entity_id, "entity_id")
        validate_resource(resource)
        now_ms = int(time.time() * 1000)
        child_limits, child_config_source = self._resolve_limits(
            entity_id, resource, limits_override
        )
        entity, child_buckets = self._fetch_entity_and_buckets(entity_id, resource)
        entity_ids = [entity_id]
        existing_buckets: dict[tuple[str, str, str], BucketState] = dict(child_buckets)
        entity_limits: dict[str, list[Limit]] = {entity_id: child_limits}
        entity_config_sources: dict[str, str] = {entity_id: child_config_source}
        if entity and entity.cascade and entity.parent_id:
            parent_id = entity.parent_id
            entity_ids.append(parent_id)
            parent_limits, parent_config_source = self._resolve_limits(
                parent_id, resource, limits_override
            )
            entity_limits[parent_id] = parent_limits
            entity_config_sources[parent_id] = parent_config_source
            parent_buckets = self._fetch_buckets([parent_id], resource)
            existing_buckets.update(parent_buckets)
        entries: list[LeaseEntry] = []
        statuses: list[LimitStatus] = []
        for eid in entity_ids:
            any_existing = any(
                (eid, resource, limit.name) in existing_buckets for limit in entity_limits[eid]
            )
            for limit in entity_limits[eid]:
                bucket_key = (eid, resource, limit.name)
                existing = existing_buckets.get(bucket_key)
                if existing is None:
                    is_new = True
                    state = BucketState.from_limit(eid, resource, limit, now_ms)
                else:
                    is_new = False
                    state = existing
                original_tk = state.tokens_milli
                original_rf = state.last_refill_ms
                amount = consume.get(limit.name, 0)
                result = try_consume(state, amount, now_ms)
                status = LimitStatus(
                    entity_id=eid,
                    resource=resource,
                    limit_name=limit.name,
                    limit=limit,
                    available=result.available,
                    requested=amount,
                    exceeded=not result.success,
                    retry_after_seconds=result.retry_after_seconds,
                )
                statuses.append(status)
                if result.success:
                    state.tokens_milli = result.new_tokens_milli
                    state.last_refill_ms = result.new_last_refill_ms
                    if state.total_consumed_milli is not None and amount > 0:
                        state.total_consumed_milli += amount * 1000
                has_custom_config = entity_config_sources.get(eid) == "entity"
                entries.append(
                    LeaseEntry(
                        entity_id=eid,
                        resource=resource,
                        limit=limit,
                        state=state,
                        consumed=amount if result.success else 0,
                        _original_tokens_milli=original_tk,
                        _original_rf_ms=original_rf,
                        _is_new=is_new and (not any_existing),
                        _has_custom_config=has_custom_config,
                        _cascade=entity.cascade if entity and eid == entity_id else False,
                        _parent_id=entity.parent_id if entity and eid == entity_id else None,
                    )
                )
        violations = [s for s in statuses if s.exceeded]
        if violations:
            raise RateLimitExceeded(statuses)
        return SyncLease(repository=self._repository, entries=entries)

    def _fetch_entity_and_buckets(
        self, entity_id: str, resource: str
    ) -> tuple[Entity | None, dict[tuple[str, str, str], BucketState]]:
        """
        Fetch entity metadata and its composite bucket in a single call.

        With composite items (ADR-114), one item per (entity_id, resource)
        contains all limits. Uses batch_get_entity_and_buckets if the backend
        supports batch operations, otherwise falls back to separate calls.
        """
        if self._repository.capabilities.supports_batch_operations:
            bucket_keys = [(entity_id, resource)]
            result: tuple[Entity | None, dict[tuple[str, str, str], BucketState]] = (
                self._repository.batch_get_entity_and_buckets(entity_id, bucket_keys)
            )
            return result
        entity = self._repository.get_entity(entity_id)
        buckets = self._repository.get_buckets(entity_id, resource)
        bucket_dict: dict[tuple[str, str, str], BucketState] = {
            (b.entity_id, b.resource, b.limit_name): b for b in buckets
        }
        return (entity, bucket_dict)

    def _fetch_buckets(
        self, entity_ids: list[str], resource: str
    ) -> dict[tuple[str, str, str], BucketState]:
        """
        Fetch composite buckets for entity/resource pairs.

        With composite items (ADR-114), each (entity_id, resource) is one
        DynamoDB item containing all limits. Uses batch_get_buckets if the
        backend supports it, otherwise falls back to sequential calls.

        Args:
            entity_ids: List of entity IDs to fetch buckets for
            resource: Resource name

        Returns:
            Dict mapping (entity_id, resource, limit_name) to BucketState.
            Missing buckets are not included in the result.
        """
        if self._repository.capabilities.supports_batch_operations:
            bucket_keys: list[tuple[str, str]] = [(eid, resource) for eid in entity_ids]
            batch_result: dict[tuple[str, str, str], BucketState] = (
                self._repository.batch_get_buckets(bucket_keys)
            )
            return batch_result
        result: dict[tuple[str, str, str], BucketState] = {}
        for eid in entity_ids:
            buckets = self._repository.get_buckets(eid, resource)
            for bucket in buckets:
                key = (bucket.entity_id, bucket.resource, bucket.limit_name)
                result[key] = bucket
        return result

    def _resolve_limits(
        self, entity_id: str, resource: str, limits_override: list[Limit] | None
    ) -> tuple[list[Limit], ConfigSource | Literal["override"]]:
        """
        Resolve limits using four-tier hierarchy.

        Delegates to repository.resolve_limits() for config resolution (ADR-122).

        Hierarchy: Entity > Entity Default > Resource > System > Override.

        Args:
            entity_id: Entity to resolve limits for
            resource: Resource being accessed
            limits_override: Optional override limits (from limits parameter)

        Returns:
            Tuple of (limits, config_source) where config_source is one of:
            - "entity": Entity-level config for specific resource
            - "entity_default": Entity-level _default_ config
            - "resource": Resource-level defaults
            - "system": System-level defaults
            - "override": Override parameter provided

        Raises:
            ValidationError: If no limits found at any level and no override provided
        """
        if limits_override is not None:
            return (limits_override, "override")
        limits, _, config_source = self._repository.resolve_limits(entity_id, resource)
        if limits is not None and config_source is not None:
            return (limits, config_source)
        raise ValidationError(
            field="limits",
            value=f"entity={entity_id}, resource={resource}",
            reason=f"No limits configured for entity '{entity_id}' and resource '{resource}'. Configure limits at entity (resource-specific or _default_), resource, or system level, or provide limits parameter.",
        )

    def _resolve_on_unavailable(self, on_unavailable_param: OnUnavailable | None) -> OnUnavailable:
        """
        Resolve on_unavailable behavior: Parameter > System Config (cached).

        Delegates system config lookup to repository.resolve_on_unavailable() (#333).

        Args:
            on_unavailable_param: Optional per-call override

        Returns:
            Resolved OnUnavailable enum value
        """
        if on_unavailable_param is not None:
            return on_unavailable_param
        on_unavailable_action = self._repository.resolve_on_unavailable()
        return OnUnavailable(on_unavailable_action)

    def available(
        self,
        entity_id: str,
        resource: str,
        limits: list[Limit] | None = None,
        use_stored_limits: bool = False,
    ) -> dict[str, int]:
        """
        Check available capacity without consuming.

        Limits are resolved using four-tier hierarchy: Entity > Entity Default > Resource > System.
        If no stored limits found, falls back to the `limits` parameter.

        Returns minimum available across entity (and parent if cascade).
        Can return negative values if bucket is in debt.

        Args:
            entity_id: Entity to check
            resource: Resource to check
            limits: Override limits (optional, falls back to stored config)
            use_stored_limits: DEPRECATED - limits are now always resolved from
                stored config. This parameter will be removed in v1.0.

        Returns:
            Dict mapping limit_name -> available tokens

        Raises:
            ValidationError: If no limits found at any level and no override provided
        """
        self._ensure_initialized()
        now_ms = int(time.time() * 1000)
        if use_stored_limits:
            warnings.warn(
                "use_stored_limits is deprecated and will be removed in v1.0. Limits are now always resolved from stored config (Entity > Resource > System). Pass limits parameter as override if needed.",
                DeprecationWarning,
                stacklevel=2,
            )
        resolved_limits, _ = self._resolve_limits(entity_id, resource, limits)
        result: dict[str, int] = {}
        for limit in resolved_limits:
            state = self._repository.get_bucket(entity_id, resource, limit.name)
            if state is None:
                result[limit.name] = limit.capacity
            else:
                result[limit.name] = calculate_available(state, now_ms)
        return result

    def time_until_available(
        self,
        entity_id: str,
        resource: str,
        needed: dict[str, int],
        limits: list[Limit] | None = None,
        use_stored_limits: bool = False,
    ) -> float:
        """
        Calculate seconds until requested capacity is available.

        Limits are resolved using four-tier hierarchy: Entity > Entity Default > Resource > System.
        If no stored limits found, falls back to the `limits` parameter.

        Args:
            entity_id: Entity to check
            resource: Resource to check
            needed: Required amounts by limit name
            limits: Override limits (optional, falls back to stored config)
            use_stored_limits: DEPRECATED - limits are now always resolved from
                stored config. This parameter will be removed in v1.0.

        Returns:
            Seconds until available (0.0 if already available)

        Raises:
            ValidationError: If no limits found at any level and no override provided
        """
        self._ensure_initialized()
        now_ms = int(time.time() * 1000)
        if use_stored_limits:
            warnings.warn(
                "use_stored_limits is deprecated and will be removed in v1.0. Limits are now always resolved from stored config (Entity > Resource > System). Pass limits parameter as override if needed.",
                DeprecationWarning,
                stacklevel=2,
            )
        resolved_limits, _ = self._resolve_limits(entity_id, resource, limits)
        max_wait = 0.0
        for limit in resolved_limits:
            amount = needed.get(limit.name, 0)
            if amount <= 0:
                continue
            state = self._repository.get_bucket(entity_id, resource, limit.name)
            if state is None:
                continue
            wait = calculate_time_until_available(state, amount, now_ms)
            max_wait = max(max_wait, wait)
        return max_wait

    def set_limits(
        self,
        entity_id: str,
        limits: list[Limit],
        resource: str = DEFAULT_RESOURCE,
        principal: str | None = None,
    ) -> None:
        """
        Store limit configs for an entity.

        Args:
            entity_id: Entity to set limits for
            limits: Limits to store
            resource: Resource these limits apply to (or _default_)
            principal: Caller identity for audit logging (optional)
        """
        self._ensure_initialized()
        self._repository.set_limits(entity_id, limits, resource, principal=principal)

    def get_limits(self, entity_id: str, resource: str = DEFAULT_RESOURCE) -> list[Limit]:
        """
        Get stored limit configs for an entity.

        Args:
            entity_id: Entity to get limits for
            resource: Resource to get limits for

        Returns:
            List of stored limits (empty if none)
        """
        self._ensure_initialized()
        return self._repository.get_limits(entity_id, resource)

    def delete_limits(
        self, entity_id: str, resource: str = DEFAULT_RESOURCE, principal: str | None = None
    ) -> None:
        """
        Delete stored limit configs for an entity.

        Reconciles existing buckets to fallback config (resource/system
        defaults) by syncing limit fields, setting TTL, and removing
        stale limit attributes (issue #327).

        Args:
            entity_id: Entity to delete limits for
            resource: Resource to delete limits for
            principal: Caller identity for audit logging (optional)
        """
        self._ensure_initialized()
        old_limits = self._repository.get_limits(entity_id, resource)
        self._repository.delete_limits(entity_id, resource, principal=principal)
        try:
            effective_limits, _ = self._resolve_limits(entity_id, resource, limits_override=None)
        except ValidationError:
            return
        stale_names = {lim.name for lim in old_limits} - {lim.name for lim in effective_limits}
        self._repository.reconcile_bucket_to_defaults(
            entity_id,
            resource,
            effective_limits,
            stale_limit_names=stale_names if stale_names else None,
        )

    def list_entities_with_custom_limits(
        self, resource: str, limit: int | None = None, cursor: str | None = None
    ) -> tuple[list[str], str | None]:
        """
        List all entities that have custom limit configurations.

        Uses GSI3 sparse index for efficient queries. Only entities with
        custom limits for the specified resource are returned.

        Args:
            resource: Resource to filter by.
            limit: Maximum number of entities to return. None for all.
            cursor: Pagination cursor from previous call.

        Returns:
            Tuple of (entity_ids, next_cursor). next_cursor is None if no more results.

        Example:
            # Get all entities with custom limits for gpt-4
            entities, cursor = limiter.list_entities_with_custom_limits("gpt-4")
            for entity_id in entities:
                print(entity_id)

            # Paginate through results
            while cursor:
                more, cursor = limiter.list_entities_with_custom_limits(
                    "gpt-4", cursor=cursor
                )
                entities.extend(more)
        """
        self._ensure_initialized()
        return self._repository.list_entities_with_custom_limits(resource, limit, cursor)

    def list_resources_with_entity_configs(self) -> list[str]:
        """
        List all resources that have entity-level custom limit configurations.

        Uses the entity config resources registry for efficient O(1) lookup.

        Returns:
            Sorted list of resource names with at least one entity having custom limits

        Example:
            resources = limiter.list_resources_with_entity_configs()
            for resource in resources:
                entities, _ = limiter.list_entities_with_custom_limits(resource)
                print(f"{resource}: {len(entities)} entities with custom limits")
        """
        self._ensure_initialized()
        return self._repository.list_resources_with_entity_configs()

    def set_resource_defaults(
        self, resource: str, limits: list[Limit], principal: str | None = None
    ) -> None:
        """
        Store default limit configs for a resource.

        Resource defaults override system defaults for the specified resource.

        Args:
            resource: Resource name
            limits: Limits to store
            principal: Caller identity for audit logging (optional)
        """
        self._ensure_initialized()
        self._repository.set_resource_defaults(resource, limits, principal=principal)

    def get_resource_defaults(self, resource: str) -> list[Limit]:
        """
        Get stored default limit configs for a resource.

        Args:
            resource: Resource name

        Returns:
            List of stored limits (empty if none)
        """
        self._ensure_initialized()
        return self._repository.get_resource_defaults(resource)

    def delete_resource_defaults(self, resource: str, principal: str | None = None) -> None:
        """
        Delete stored default limit configs for a resource.

        Args:
            resource: Resource name
            principal: Caller identity for audit logging (optional)
        """
        self._ensure_initialized()
        self._repository.delete_resource_defaults(resource, principal=principal)

    def list_resources_with_defaults(self) -> list[str]:
        """List all resources that have default limit configs."""
        self._ensure_initialized()
        return self._repository.list_resources_with_defaults()

    def set_system_defaults(
        self,
        limits: list[Limit],
        on_unavailable: OnUnavailable | None = None,
        principal: str | None = None,
    ) -> None:
        """
        Store system-wide default limits and config.

        System defaults apply to ALL resources unless overridden at resource
        or entity level.

        Args:
            limits: Limits to store (apply globally to all resources)
            on_unavailable: Behavior when DynamoDB unavailable (optional)
            principal: Caller identity for audit logging (optional)
        """
        self._ensure_initialized()
        on_unavailable_action: OnUnavailableAction | None = (
            on_unavailable.value if on_unavailable else None
        )
        self._repository.set_system_defaults(
            limits, on_unavailable=on_unavailable_action, principal=principal
        )

    def get_system_defaults(self) -> tuple[list[Limit], OnUnavailable | None]:
        """
        Get system-wide default limits and config.

        Returns:
            Tuple of (limits, on_unavailable). on_unavailable may be None if not set.
        """
        self._ensure_initialized()
        limits, on_unavailable_action = self._repository.get_system_defaults()
        on_unavailable = OnUnavailable(on_unavailable_action) if on_unavailable_action else None
        return (limits, on_unavailable)

    def delete_system_defaults(self, principal: str | None = None) -> None:
        """
        Delete all system-wide default limits and config.

        Args:
            principal: Caller identity for audit logging (optional)
        """
        self._ensure_initialized()
        self._repository.delete_system_defaults(principal=principal)

    def get_resource_capacity(
        self, resource: str, limit_name: str, parents_only: bool = False
    ) -> ResourceCapacity:
        """
        Get aggregated capacity for a resource across all entities.

        Args:
            resource: Resource to query
            limit_name: Limit name to query
            parents_only: If True, only include parent entities

        Returns:
            ResourceCapacity with aggregated data
        """
        self._ensure_initialized()
        now_ms = int(time.time() * 1000)
        buckets = self._repository.get_resource_buckets(resource, limit_name)
        if parents_only:
            parent_ids = set()
            for bucket in buckets:
                entity = self._repository.get_entity(bucket.entity_id)
                if entity and entity.is_parent:
                    parent_ids.add(bucket.entity_id)
            buckets = [b for b in buckets if b.entity_id in parent_ids]
        entity_buckets: dict[str, list[BucketState]] = {}
        for bucket in buckets:
            entity_buckets.setdefault(bucket.entity_id, []).append(bucket)
        entities: list[EntityCapacity] = []
        total_capacity = 0
        total_available = 0
        for entity_id, entity_bucket_list in entity_buckets.items():
            capacity = entity_bucket_list[0].capacity
            available = sum(calculate_available(b, now_ms) for b in entity_bucket_list)
            available = min(available, capacity)
            total_capacity += capacity
            total_available += available
            entities.append(
                EntityCapacity(
                    entity_id=entity_id,
                    capacity=capacity,
                    available=available,
                    utilization_pct=(capacity - available) / capacity * 100 if capacity > 0 else 0,
                )
            )
        return ResourceCapacity(
            resource=resource,
            limit_name=limit_name,
            total_capacity=total_capacity,
            total_available=total_available,
            utilization_pct=(total_capacity - total_available) / total_capacity * 100
            if total_capacity > 0
            else 0,
            entities=entities,
        )
