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

Source: test_repository.py

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

import time
from unittest.mock import MagicMock, patch

import pytest
from botocore.exceptions import ClientError

from zae_limiter import AuditAction, Limit
from zae_limiter.exceptions import EntityExistsError, InvalidIdentifierError
from zae_limiter.models import BucketState
from zae_limiter.schema import (
    calculate_bucket_ttl,
    limit_attr,
    parse_bucket_attr,
    parse_bucket_sk,
    parse_limit_attr,
    sk_config,
)
from zae_limiter.sync_repository import SyncRepository
from zae_limiter.sync_repository_protocol import SpeculativeFailureReason


@pytest.fixture
def repo(mock_dynamodb):
    """Basic repository instance."""
    repo = SyncRepository(name="test-repo", region="us-east-1", _skip_deprecation_warning=True)
    repo.create_table()
    yield repo
    repo.close()


@pytest.fixture
def repo_with_buckets(repo):
    """SyncRepository pre-populated with test buckets (composite items, ADR-114)."""
    repo.create_entity("entity-1", parent_id=None, name="Entity 1")
    repo.create_entity("entity-2", parent_id="entity-1", name="Entity 2")
    limits = [Limit.per_minute("rpm", 100), Limit.per_minute("tpm", 10000)]
    now_ms = int(time.time() * 1000)
    for entity_id in ["entity-1", "entity-2"]:
        for resource in ["gpt-4", "gpt-3.5"]:
            states = [
                BucketState.from_limit(entity_id, resource, limit, now_ms) for limit in limits
            ]
            put_item = repo.build_composite_create(entity_id, resource, states, now_ms)
            repo.transact_write([put_item])
    yield repo


class TestBucketTTLCalculation:
    """Tests for calculate_bucket_ttl (Issue #271, #296: Time-to-fill based TTL)."""

    def test_calculate_bucket_ttl_single_limit(self):
        """TTL = now + time_to_fill × multiplier for single limit.

        For Limit.per_minute("rpm", 100): capacity=100, refill_amount=100
        time_to_fill = (100/100) × 60 = 60 seconds
        """
        now_ms = 1700000000000
        limits = [Limit.per_minute("rpm", 100)]
        multiplier = 7
        ttl = calculate_bucket_ttl(now_ms, limits, multiplier)
        assert ttl == 1700000420

    def test_calculate_bucket_ttl_slow_refill_limit(self):
        """TTL accounts for slow refill rate (Issue #296).

        For slow-refill limit: capacity=1000, refill_amount=10, refill_period=60s
        time_to_fill = (1000/10) × 60 = 6000 seconds (100 minutes)
        TTL should be 6000 × 7 = 42000 seconds, NOT 60 × 7 = 420 seconds
        """
        now_ms = 1700000000000
        slow_refill_limit = Limit(
            name="tokens", capacity=1000, refill_amount=10, refill_period_seconds=60
        )
        limits = [slow_refill_limit]
        multiplier = 7
        ttl = calculate_bucket_ttl(now_ms, limits, multiplier)
        assert ttl == 1700042000

    def test_calculate_bucket_ttl_multiple_limits_uses_max_time_to_fill(self):
        """TTL uses maximum time_to_fill when multiple limits exist."""
        now_ms = 1700000000000
        limits = [Limit.per_minute("rpm", 100), Limit.per_day("tpd", 1000000)]
        multiplier = 7
        ttl = calculate_bucket_ttl(now_ms, limits, multiplier)
        assert ttl == 1700604800

    def test_calculate_bucket_ttl_multiple_limits_slow_refill_wins(self):
        """Slow refill limit should dominate even with shorter refill_period.

        Fast limit: per_minute(100) -> time_to_fill = 60s
        Slow limit: capacity=1000, refill_amount=10, period=60s -> time_to_fill = 6000s
        The slow limit should determine TTL even though both have same refill_period.
        """
        now_ms = 1700000000000
        limits = [
            Limit.per_minute("rpm", 100),
            Limit(name="slow", capacity=1000, refill_amount=10, refill_period_seconds=60),
        ]
        multiplier = 7
        ttl = calculate_bucket_ttl(now_ms, limits, multiplier)
        assert ttl == 1700042000

    def test_calculate_bucket_ttl_returns_none_when_multiplier_zero(self):
        """TTL is None when multiplier is 0 (disabled)."""
        now_ms = 1700000000000
        limits = [Limit.per_minute("rpm", 100)]
        multiplier = 0
        ttl = calculate_bucket_ttl(now_ms, limits, multiplier)
        assert ttl is None

    def test_calculate_bucket_ttl_returns_none_when_multiplier_negative(self):
        """TTL is None when multiplier is negative (disabled)."""
        now_ms = 1700000000000
        limits = [Limit.per_minute("rpm", 100)]
        multiplier = -1
        ttl = calculate_bucket_ttl(now_ms, limits, multiplier)
        assert ttl is None


class TestSchemaCompositeKeys:
    """Tests for composite bucket schema key builders."""

    def test_parse_bucket_attr_valid(self):
        """parse_bucket_attr returns (limit_name, field) for valid attributes."""
        assert parse_bucket_attr("b_rpm_tk") == ("rpm", "tk")
        assert parse_bucket_attr("b_tpm_cp") == ("tpm", "cp")
        assert parse_bucket_attr("b_my_limit_ra") == ("my_limit", "ra")

    def test_parse_bucket_attr_not_bucket(self):
        """parse_bucket_attr returns None for non-bucket attributes."""
        assert parse_bucket_attr("entity_id") is None
        assert parse_bucket_attr("PK") is None
        assert parse_bucket_attr("rf") is None

    def test_parse_bucket_attr_no_field_separator(self):
        """parse_bucket_attr returns None when no underscore after prefix."""
        assert parse_bucket_attr("b_") is None
        assert parse_bucket_attr("b_x") is None

    def test_parse_bucket_sk_valid(self):
        """parse_bucket_sk extracts resource from composite SK."""
        assert parse_bucket_sk("#BUCKET#gpt-4") == "gpt-4"
        assert parse_bucket_sk("#BUCKET#api") == "api"

    def test_parse_bucket_sk_invalid_prefix(self):
        """parse_bucket_sk raises ValueError for non-bucket SK."""
        with pytest.raises(ValueError, match="Invalid bucket SK"):
            parse_bucket_sk("#META")

    def test_parse_bucket_sk_empty_resource(self):
        """parse_bucket_sk raises ValueError for empty resource."""
        with pytest.raises(ValueError, match="Invalid bucket SK format"):
            parse_bucket_sk("#BUCKET#")


class TestSchemaCompositeLimitKeys:
    """Tests for composite limit config schema key builders."""

    def test_limit_attr_builds_correct_format(self):
        """limit_attr builds l_{name}_{field} format."""
        assert limit_attr("rpm", "cp") == "l_rpm_cp"
        assert limit_attr("tpm", "ra") == "l_tpm_ra"
        assert limit_attr("my_limit", "ra") == "l_my_limit_ra"

    def test_parse_limit_attr_valid(self):
        """parse_limit_attr returns (limit_name, field) for valid attributes."""
        assert parse_limit_attr("l_rpm_cp") == ("rpm", "cp")
        assert parse_limit_attr("l_tpm_ra") == ("tpm", "ra")
        assert parse_limit_attr("l_my_limit_ra") == ("my_limit", "ra")

    def test_parse_limit_attr_not_limit(self):
        """parse_limit_attr returns None for non-limit attributes."""
        assert parse_limit_attr("entity_id") is None
        assert parse_limit_attr("PK") is None
        assert parse_limit_attr("b_rpm_tk") is None

    def test_parse_limit_attr_no_field_separator(self):
        """parse_limit_attr returns None when no underscore after prefix."""
        assert parse_limit_attr("l_") is None
        assert parse_limit_attr("l_x") is None

    def test_sk_config_without_resource(self):
        """sk_config() returns #CONFIG when no resource provided."""
        assert sk_config() == "#CONFIG"
        assert sk_config(None) == "#CONFIG"

    def test_sk_config_with_resource(self):
        """sk_config(resource) returns #CONFIG#{resource}."""
        assert sk_config("gpt-4") == "#CONFIG#gpt-4"
        assert sk_config("_default_") == "#CONFIG#_default_"


class TestRepositoryBucketOperations:
    """Tests for bucket CRUD and queries."""

    def test_get_bucket_returns_none_for_nonexistent(self, repo):
        """Getting a nonexistent bucket should return None."""
        result = repo.get_bucket("nonexistent", "gpt-4", "rpm")
        assert result is None

    def test_get_buckets_filters_by_resource(self, repo_with_buckets):
        """get_buckets should filter by resource when specified."""
        buckets = repo_with_buckets.get_buckets("entity-1", resource="gpt-4")
        assert len(buckets) == 2
        assert all(b.resource == "gpt-4" for b in buckets)
        limit_names = {b.limit_name for b in buckets}
        assert limit_names == {"rpm", "tpm"}

    def test_get_buckets_returns_all_when_no_filter(self, repo_with_buckets):
        """get_buckets should return all buckets when no resource filter."""
        buckets = repo_with_buckets.get_buckets("entity-1")
        assert len(buckets) == 4
        resources = {b.resource for b in buckets}
        assert resources == {"gpt-4", "gpt-3.5"}
        limit_names = {b.limit_name for b in buckets}
        assert limit_names == {"rpm", "tpm"}

    def test_build_bucket_update_with_optimistic_locking(self, repo):
        """Optimistic locking should add conditional expression."""
        update_item = repo.build_bucket_update_item(
            entity_id="entity-1",
            resource="gpt-4",
            limit_name="rpm",
            new_tokens_milli=75000,
            new_last_refill_ms=1234567890,
            expected_tokens_milli=100000,
        )
        assert "Update" in update_item
        update_spec = update_item["Update"]
        expected_expr = "SET #tokens = :tokens, #refill = :refill"
        assert update_spec["UpdateExpression"] == expected_expr
        assert "#data" not in update_spec["ExpressionAttributeNames"]
        assert update_spec["ExpressionAttributeNames"]["#tokens"] == "b_rpm_tk"
        assert update_spec["ExpressionAttributeNames"]["#refill"] == "rf"
        assert update_spec["ExpressionAttributeValues"][":tokens"] == {"N": "75000"}
        assert update_spec["ExpressionAttributeValues"][":refill"] == {"N": "1234567890"}
        assert update_spec["ExpressionAttributeValues"][":expected"] == {"N": "100000"}
        assert update_spec["ConditionExpression"] == "#tokens = :expected"

    def test_build_bucket_update_without_optimistic_locking(self, repo):
        """Without expected_tokens, no condition should be added."""
        update_item = repo.build_bucket_update_item(
            entity_id="entity-1",
            resource="gpt-4",
            limit_name="rpm",
            new_tokens_milli=75000,
            new_last_refill_ms=1234567890,
            expected_tokens_milli=None,
        )
        assert "Update" in update_item
        update_spec = update_item["Update"]
        assert "ConditionExpression" not in update_spec
        assert ":expected" not in update_spec["ExpressionAttributeValues"]
        expected_expr = "SET #tokens = :tokens, #refill = :refill"
        assert update_spec["UpdateExpression"] == expected_expr

    def test_batch_get_buckets_empty_keys(self, repo):
        """batch_get_buckets should return empty dict for empty keys list."""
        result = repo.batch_get_buckets([])
        assert result == {}

    def test_batch_get_configs_empty_keys(self, repo):
        """batch_get_configs should return empty dict for empty keys list."""
        result = repo.batch_get_configs([])
        assert result == {}

    def test_batch_get_configs_all_present(self, repo):
        """batch_get_configs returns deserialized (limits, on_unavailable) tuples."""
        from zae_limiter import Limit, schema

        repo.set_system_defaults([Limit.per_minute("rpm", 1000)], on_unavailable="allow")
        repo.set_resource_defaults("gpt-4", [Limit.per_minute("rpm", 500)])
        repo.create_entity("user-1")
        repo.set_limits("user-1", [Limit.per_minute("rpm", 100)], resource="gpt-4")
        repo.set_limits("user-1", [Limit.per_minute("rpm", 200)], resource="_default_")
        keys = [
            (schema.pk_system("default"), schema.sk_config()),
            (schema.pk_resource("default", "gpt-4"), schema.sk_config()),
            (schema.pk_entity("default", "user-1"), schema.sk_config("gpt-4")),
            (schema.pk_entity("default", "user-1"), schema.sk_config("_default_")),
        ]
        result = repo.batch_get_configs(keys)
        assert len(result) == 4
        for key in keys:
            assert key in result
            limits, on_unavailable = result[key]
            assert isinstance(limits, list)
        sys_limits, sys_ou = result[schema.pk_system("default"), schema.sk_config()]
        assert len(sys_limits) == 1
        assert sys_limits[0].name == "rpm"
        assert sys_limits[0].capacity == 1000
        assert sys_ou == "allow"
        res_limits, res_ou = result[schema.pk_resource("default", "gpt-4"), schema.sk_config()]
        assert len(res_limits) == 1
        assert res_limits[0].capacity == 500
        assert res_ou is None

    def test_batch_get_configs_partial_results(self, repo):
        """batch_get_configs returns only present items (missing ones omitted)."""
        from zae_limiter import Limit, schema

        repo.set_system_defaults([Limit.per_minute("rpm", 1000)])
        keys = [
            (schema.pk_system("default"), schema.sk_config()),
            (schema.pk_resource("default", "gpt-4"), schema.sk_config()),
            (schema.pk_entity("default", "user-1"), schema.sk_config("gpt-4")),
        ]
        result = repo.batch_get_configs(keys)
        assert len(result) == 1
        sys_key = (schema.pk_system("default"), schema.sk_config())
        assert sys_key in result
        limits, on_unavailable = result[sys_key]
        assert len(limits) == 1
        assert limits[0].name == "rpm"

    def test_get_or_create_bucket_creates_new(self, repo):
        """get_or_create_bucket should create a new bucket if it doesn't exist."""
        from zae_limiter import Limit

        limit = Limit.per_minute("rpm", 100)
        bucket = repo.get_or_create_bucket("entity-1", "gpt-4", limit)
        assert bucket is not None
        assert bucket.entity_id == "entity-1"
        assert bucket.resource == "gpt-4"
        assert bucket.limit_name == "rpm"
        assert bucket.tokens_milli == 100000

    def test_get_or_create_bucket_returns_existing(self, repo):
        """get_or_create_bucket should return existing bucket if it exists."""
        from zae_limiter import Limit

        limit = Limit.per_minute("rpm", 100)
        bucket1 = repo.get_or_create_bucket("entity-2", "gpt-4", limit)
        bucket2 = repo.get_or_create_bucket("entity-2", "gpt-4", limit)
        assert bucket1.entity_id == bucket2.entity_id
        assert bucket1.resource == bucket2.resource
        assert bucket1.limit_name == bucket2.limit_name


class TestRepositoryResourceAggregation:
    """Tests for GSI2 resource queries."""

    def test_get_resource_buckets_all_entities(self, repo_with_buckets):
        """Should query all buckets for a resource via GSI2."""
        buckets = repo_with_buckets.get_resource_buckets("gpt-4", "rpm")
        assert len(buckets) == 2
        assert all(b.resource == "gpt-4" for b in buckets)
        assert all(b.limit_name == "rpm" for b in buckets)
        entity_ids = {b.entity_id for b in buckets}
        assert entity_ids == {"entity-1", "entity-2"}

    def test_get_resource_buckets_filtered_by_limit_name(self, repo_with_buckets):
        """Should filter by limit_name when specified."""
        rpm_buckets = repo_with_buckets.get_resource_buckets("gpt-4", "rpm")
        tpm_buckets = repo_with_buckets.get_resource_buckets("gpt-4", "tpm")
        assert len(rpm_buckets) == 2
        assert len(tpm_buckets) == 2
        assert all(b.limit_name == "rpm" for b in rpm_buckets)
        assert all(b.limit_name == "tpm" for b in tpm_buckets)

    def test_get_resource_buckets_empty_result(self, repo):
        """Should return empty list when no buckets match."""
        buckets = repo.get_resource_buckets("nonexistent-resource", "rpm")
        assert buckets == []


class TestRepositoryTransactions:
    """Tests for transactional writes and edge cases."""

    def test_transact_write_empty_items_list(self, repo):
        """transact_write should handle empty items list."""
        repo.transact_write([])

    def test_transact_write_single_delete(self, repo):
        """transact_write dispatches single Delete item via delete_item API."""
        repo.create_entity("tw-delete-test")
        delete_item = {
            "Delete": {
                "TableName": repo.table_name,
                "Key": {"PK": {"S": "default/ENTITY#tw-delete-test"}, "SK": {"S": "#META"}},
            }
        }
        repo.transact_write([delete_item])
        entity = repo.get_entity("tw-delete-test")
        assert entity is None

    def test_transact_write_single_unknown_type_falls_through(self, repo):
        """transact_write falls through to transact_write_items for unknown item types."""
        repo.create_entity("tw-condcheck-test")
        condition_item = {
            "ConditionCheck": {
                "TableName": repo.table_name,
                "Key": {"PK": {"S": "default/ENTITY#tw-condcheck-test"}, "SK": {"S": "#META"}},
                "ConditionExpression": "attribute_exists(PK)",
            }
        }
        repo.transact_write([condition_item])

    def test_write_each_empty_items_list(self, repo):
        """write_each should handle empty items list."""
        repo.write_each([])

    def test_write_each_dispatches_each_item_independently(self, repo):
        """write_each dispatches Put, Update, and Delete as independent calls."""
        limit = Limit.per_minute("rpm", 100)
        now_ms = int(time.time() * 1000)
        repo.create_entity("we-test")
        state = BucketState.from_limit("we-test", "api", limit, now_ms)
        put_item = repo.build_bucket_put_item(state)
        repo.write_each([put_item])
        buckets = repo.get_buckets("we-test", "api")
        assert len(buckets) == 1
        adjust_item = repo.build_composite_adjust(
            entity_id="we-test", resource="api", deltas={"rpm": -5000}
        )
        repo.write_each([adjust_item])
        delete_item = {
            "Delete": {
                "TableName": repo.table_name,
                "Key": {"PK": {"S": "default/ENTITY#we-test"}, "SK": {"S": "#META"}},
            }
        }
        repo.write_each([delete_item])
        entity = repo.get_entity("we-test")
        assert entity is None

    def test_build_bucket_put_item_structure(self, repo):
        """build_bucket_put_item should create composite DynamoDB structure (ADR-114)."""
        limit = Limit.per_minute("rpm", 100)
        now_ms = int(time.time() * 1000)
        state = BucketState.from_limit("entity-1", "gpt-4", limit, now_ms)
        put_item = repo.build_bucket_put_item(state)
        assert "Put" in put_item
        put_spec = put_item["Put"]
        assert put_spec["TableName"] == "test-repo"
        assert "PK" in put_spec["Item"]
        assert "SK" in put_spec["Item"]
        assert put_spec["Item"]["PK"]["S"] == "default/BUCKET#entity-1#gpt-4#0"
        assert put_spec["Item"]["SK"]["S"] == "#STATE"
        assert "data" not in put_spec["Item"]
        item = put_spec["Item"]
        assert item["b_rpm_tk"]["N"] == str(100000)
        assert item["b_rpm_cp"]["N"] == str(100000)
        assert item["b_rpm_ra"]["N"] == str(100000)
        assert item["b_rpm_rp"]["N"] == str(60000)
        assert item["resource"]["S"] == "gpt-4"
        assert "rf" in item
        assert "attribute_not_exists(PK)" in put_spec.get("ConditionExpression", "")

    def test_batch_delete_pagination_over_25_items(self, repo):
        """Batch delete should handle >25 items by chunking."""
        for i in range(30):
            repo.create_entity(f"entity-{i}")
        limit = Limit.per_minute("rpm", 100)
        now_ms = int(time.time() * 1000)
        for i in range(30):
            state = BucketState.from_limit(f"entity-{i}", "api", limit, now_ms)
            repo.transact_write([repo.build_bucket_put_item(state)])
        repo.delete_entity("entity-0")
        entity = repo.get_entity("entity-0")
        assert entity is None


class TestCompositeWritePaths:
    """Tests for composite bucket write path builders (ADR-115)."""

    def test_build_composite_retry_structure(self, repo):
        """build_composite_retry produces ADD for tk and tc per limit."""
        result = repo.build_composite_retry(
            entity_id="entity-1", resource="gpt-4", consumed={"rpm": 5000, "tpm": 100000}
        )
        assert "Update" in result
        update = result["Update"]
        assert update["Key"]["PK"]["S"] == "default/BUCKET#entity-1#gpt-4#0"
        assert update["Key"]["SK"]["S"] == "#STATE"
        expr = update["UpdateExpression"]
        assert "ADD" in expr
        assert "#b_rpm_tk" in expr
        assert "#b_rpm_tc" in expr
        assert "#b_tpm_tk" in expr
        assert "#b_tpm_tc" in expr
        cond = update["ConditionExpression"]
        assert "#b_rpm_tk >= " in cond
        assert "#b_tpm_tk >= " in cond
        names = update["ExpressionAttributeNames"]
        assert names["#b_rpm_tk"] == "b_rpm_tk"
        assert names["#b_rpm_tc"] == "b_rpm_tc"
        vals = update["ExpressionAttributeValues"]
        assert vals[":b_rpm_tk_neg"]["N"] == "-5000"
        assert vals[":b_rpm_tc_delta"]["N"] == "5000"

    def test_build_composite_adjust_structure(self, repo):
        """build_composite_adjust produces unconditional ADD for tk and tc."""
        result = repo.build_composite_adjust(
            entity_id="entity-1", resource="gpt-4", deltas={"rpm": 3000, "tpm": -500}
        )
        assert "Update" in result
        update = result["Update"]
        assert "ADD" in update["UpdateExpression"]
        assert "ConditionExpression" not in update
        vals = update["ExpressionAttributeValues"]
        assert vals[":b_rpm_tk_delta"]["N"] == "-3000"
        assert vals[":b_rpm_tc_delta"]["N"] == "3000"
        assert vals[":b_tpm_tk_delta"]["N"] == "500"
        assert vals[":b_tpm_tc_delta"]["N"] == "-500"

    def test_build_composite_adjust_zero_deltas(self, repo):
        """build_composite_adjust with all-zero deltas returns empty dict."""
        result = repo.build_composite_adjust(
            entity_id="entity-1", resource="gpt-4", deltas={"rpm": 0}
        )
        assert result == {}

    def test_get_bucket_returns_none_for_missing_limit(self, repo_with_buckets):
        """get_bucket returns None when limit_name not found in composite item."""
        result = repo_with_buckets.get_bucket("entity-1", "gpt-4", "nonexistent")
        assert result is None

    def test_get_buckets_returns_empty_for_missing_resource(self, repo):
        """get_buckets returns empty list when no composite item exists."""
        repo.create_entity("entity-1")
        result = repo.get_buckets("entity-1", resource="nonexistent")
        assert result == []

    def test_get_buckets_gsi3_returns_empty_for_entity_without_buckets(self, repo):
        """get_buckets with resource=None returns empty list via GSI3 when no buckets exist."""
        repo.create_entity("entity-no-buckets")
        result = repo.get_buckets("entity-no-buckets")
        assert result == []


class TestCompositeBucketTTL:
    """Tests for TTL in composite bucket build methods (Issue #271)."""

    def test_build_composite_create_sets_ttl_when_provided(self, repo):
        """build_composite_create includes ttl attribute when ttl_seconds provided."""
        now_ms = 1700000000000
        state = BucketState(
            entity_id="e1",
            resource="gpt-4",
            limit_name="rpm",
            tokens_milli=100000,
            last_refill_ms=now_ms,
            capacity_milli=100000,
            refill_amount_milli=100000,
            refill_period_ms=60000,
            total_consumed_milli=0,
        )
        result = repo.build_composite_create(
            entity_id="e1", resource="gpt-4", states=[state], now_ms=now_ms, ttl_seconds=604800
        )
        item = result["Put"]["Item"]
        assert "ttl" in item
        assert item["ttl"]["N"] == "1700604800"

    def test_build_composite_create_no_ttl_when_none(self, repo):
        """build_composite_create omits ttl when ttl_seconds is None."""
        now_ms = 1700000000000
        state = BucketState(
            entity_id="e1",
            resource="gpt-4",
            limit_name="rpm",
            tokens_milli=100000,
            last_refill_ms=now_ms,
            capacity_milli=100000,
            refill_amount_milli=100000,
            refill_period_ms=60000,
            total_consumed_milli=0,
        )
        result = repo.build_composite_create(
            entity_id="e1", resource="gpt-4", states=[state], now_ms=now_ms, ttl_seconds=None
        )
        item = result["Put"]["Item"]
        assert "ttl" not in item

    def test_build_composite_normal_updates_ttl_when_provided(self, repo):
        """build_composite_normal includes SET ttl in expression."""
        now_ms = 1700000000000
        result = repo.build_composite_normal(
            entity_id="e1",
            resource="gpt-4",
            consumed={"rpm": 1000},
            refill_amounts={"rpm": 500},
            now_ms=now_ms,
            expected_rf=now_ms - 1000,
            ttl_seconds=604800,
        )
        update = result["Update"]
        expr = update["UpdateExpression"]
        assert "#ttl" in expr
        assert ":ttl_val" in update["ExpressionAttributeValues"]
        assert update["ExpressionAttributeNames"]["#ttl"] == "ttl"
        assert update["ExpressionAttributeValues"][":ttl_val"]["N"] == "1700604800"

    def test_build_composite_normal_removes_ttl_when_zero(self, repo):
        """build_composite_normal includes REMOVE ttl when ttl_seconds is 0."""
        now_ms = 1700000000000
        result = repo.build_composite_normal(
            entity_id="e1",
            resource="gpt-4",
            consumed={"rpm": 1000},
            refill_amounts={"rpm": 500},
            now_ms=now_ms,
            expected_rf=now_ms - 1000,
            ttl_seconds=0,
        )
        update = result["Update"]
        expr = update["UpdateExpression"]
        assert "REMOVE" in expr
        assert "#ttl" in expr
        assert update["ExpressionAttributeNames"]["#ttl"] == "ttl"
        assert ":ttl_val" not in update.get("ExpressionAttributeValues", {})

    def test_build_composite_normal_no_ttl_change_when_none(self, repo):
        """build_composite_normal doesn't touch ttl when ttl_seconds is None."""
        now_ms = 1700000000000
        result = repo.build_composite_normal(
            entity_id="e1",
            resource="gpt-4",
            consumed={"rpm": 1000},
            refill_amounts={"rpm": 500},
            now_ms=now_ms,
            expected_rf=now_ms - 1000,
            ttl_seconds=None,
        )
        update = result["Update"]
        expr = update["UpdateExpression"]
        assert "#ttl" not in update.get("ExpressionAttributeNames", {})
        assert "REMOVE" not in expr or "#ttl" not in expr


class TestCompositeLimitConfig:
    """Tests for composite limit config serialization and CRUD (ADR-114 for configs)."""

    def test_serialize_composite_limits(self, repo):
        """_serialize_composite_limits adds l_* attributes to item."""
        limits = [Limit.per_minute("rpm", 100), Limit.per_minute("tpm", 10000)]
        item: dict = {}
        repo._serialize_composite_limits(limits, item)
        assert item["l_rpm_cp"]["N"] == "100"
        assert item["l_rpm_ra"]["N"] == "100"
        assert item["l_rpm_rp"]["N"] == "60"
        assert item["l_tpm_cp"]["N"] == "10000"
        assert item["l_tpm_ra"]["N"] == "10000"
        assert item["l_tpm_rp"]["N"] == "60"

    def test_deserialize_composite_limits(self, repo):
        """_deserialize_composite_limits reconstructs Limit objects from l_* attrs."""
        item = {
            "l_rpm_cp": {"N": "100"},
            "l_rpm_ra": {"N": "100"},
            "l_rpm_rp": {"N": "60"},
            "l_tpm_cp": {"N": "10000"},
            "l_tpm_ra": {"N": "10000"},
            "l_tpm_rp": {"N": "60"},
            "entity_id": {"S": "test"},
        }
        limits = repo._deserialize_composite_limits(item)
        assert len(limits) == 2
        limit_map = {limit.name: limit for limit in limits}
        assert limit_map["rpm"].capacity == 100
        assert limit_map["rpm"].refill_amount == 100
        assert limit_map["rpm"].refill_period_seconds == 60
        assert limit_map["tpm"].capacity == 10000

    def test_deserialize_composite_limits_empty_item(self, repo):
        """_deserialize_composite_limits returns empty list for item without l_* attrs."""
        item = {"entity_id": {"S": "test"}, "resource": {"S": "gpt-4"}}
        limits = repo._deserialize_composite_limits(item)
        assert limits == []

    def test_set_get_limits_roundtrip(self, repo):
        """set_limits and get_limits should round-trip correctly."""
        repo.create_entity("limit-test")
        limits = [Limit.per_minute("rpm", 100), Limit.per_minute("tpm", 10000)]
        repo.set_limits("limit-test", limits, resource="gpt-4")
        retrieved = repo.get_limits("limit-test", resource="gpt-4")
        assert len(retrieved) == 2
        limit_map = {lim.name: lim for lim in retrieved}
        assert limit_map["rpm"].capacity == 100
        assert limit_map["tpm"].capacity == 10000

    def test_delete_limits(self, repo):
        """delete_limits should remove the composite config item."""
        repo.create_entity("delete-limit-test")
        limits = [Limit.per_minute("rpm", 100)]
        repo.set_limits("delete-limit-test", limits, resource="api")
        retrieved = repo.get_limits("delete-limit-test", resource="api")
        assert len(retrieved) == 1
        repo.delete_limits("delete-limit-test", resource="api")
        retrieved = repo.get_limits("delete-limit-test", resource="api")
        assert retrieved == []

    def test_set_resource_defaults_roundtrip(self, repo):
        """set_resource_defaults and get_resource_defaults should round-trip correctly."""
        limits = [Limit.per_minute("rpm", 500), Limit.per_minute("tpm", 50000)]
        repo.set_resource_defaults("gpt-4", limits)
        retrieved = repo.get_resource_defaults("gpt-4")
        assert len(retrieved) == 2
        limit_map = {lim.name: lim for lim in retrieved}
        assert limit_map["rpm"].capacity == 500
        assert limit_map["tpm"].capacity == 50000

    def test_delete_resource_defaults(self, repo):
        """delete_resource_defaults should remove the composite config item."""
        limits = [Limit.per_minute("rpm", 100)]
        repo.set_resource_defaults("test-resource", limits)
        retrieved = repo.get_resource_defaults("test-resource")
        assert len(retrieved) == 1
        repo.delete_resource_defaults("test-resource")
        retrieved = repo.get_resource_defaults("test-resource")
        assert retrieved == []

    def test_set_system_defaults_roundtrip(self, repo):
        """set_system_defaults and get_system_defaults should round-trip correctly."""
        limits = [Limit.per_minute("rpm", 1000), Limit.per_minute("tpm", 100000)]
        repo.set_system_defaults(limits, on_unavailable="allow")
        retrieved_limits, on_unavailable = repo.get_system_defaults()
        assert len(retrieved_limits) == 2
        assert on_unavailable == "allow"
        limit_map = {lim.name: lim for lim in retrieved_limits}
        assert limit_map["rpm"].capacity == 1000
        assert limit_map["tpm"].capacity == 100000

    def test_set_system_defaults_without_on_unavailable(self, repo):
        """set_system_defaults should work without on_unavailable."""
        limits = [Limit.per_minute("rpm", 500)]
        repo.set_system_defaults(limits)
        retrieved_limits, on_unavailable = repo.get_system_defaults()
        assert len(retrieved_limits) == 1
        assert on_unavailable is None

    def test_delete_system_defaults(self, repo):
        """delete_system_defaults should remove the composite config item."""
        limits = [Limit.per_minute("rpm", 100)]
        repo.set_system_defaults(limits, on_unavailable="block")
        retrieved_limits, on_unavailable = repo.get_system_defaults()
        assert len(retrieved_limits) == 1
        assert on_unavailable == "block"
        repo.delete_system_defaults()
        retrieved_limits, on_unavailable = repo.get_system_defaults()
        assert retrieved_limits == []
        assert on_unavailable is None

    def test_get_limits_returns_empty_for_nonexistent(self, repo):
        """get_limits returns empty list for entity without config."""
        repo.create_entity("no-limits")
        limits = repo.get_limits("no-limits", resource="gpt-4")
        assert limits == []

    def test_get_resource_defaults_returns_empty_for_nonexistent(self, repo):
        """get_resource_defaults returns empty list for unconfigured resource."""
        limits = repo.get_resource_defaults("nonexistent-resource")
        assert limits == []


class TestRepositorySerialization:
    """Tests for complex DynamoDB type serialization."""

    def test_serialize_map_with_bool_values(self, repo):
        """Should correctly serialize boolean values in maps."""
        repo.create_entity("test-entity", metadata={"is_active": True, "is_premium": False})
        entity = repo.get_entity("test-entity")
        assert entity is not None
        assert entity.metadata["is_active"] is True
        assert entity.metadata["is_premium"] is False

    def test_serialize_map_with_null_values(self, repo):
        """Should correctly serialize None/null values."""
        repo.create_entity("test-entity", parent_id=None)
        entity = repo.get_entity("test-entity")
        assert entity is not None
        assert entity.parent_id is None

    def test_serialize_map_with_nested_maps(self, repo):
        """Should handle nested dictionaries."""
        metadata = {
            "tier": "premium",
            "limits": {"rpm": 1000, "tpm": 50000},
            "features": {"advanced": True, "beta": False},
        }
        repo.create_entity("test-entity", metadata=metadata)
        entity = repo.get_entity("test-entity")
        assert entity is not None
        assert entity.metadata["tier"] == "premium"
        assert entity.metadata["limits"]["rpm"] == 1000
        assert entity.metadata["limits"]["tpm"] == 50000
        assert entity.metadata["features"]["advanced"] is True
        assert entity.metadata["features"]["beta"] is False

    def test_serialize_value_with_list_of_mixed_types(self, repo):
        """Should handle lists with mixed types."""
        metadata = {
            "tags": ["production", "api", "v2"],
            "numbers": [1, 2, 3, 100],
            "mixed": ["text", 42, True],
        }
        repo.create_entity("test-entity", metadata=metadata)
        entity = repo.get_entity("test-entity")
        assert entity is not None
        assert entity.metadata["tags"] == ["production", "api", "v2"]
        assert entity.metadata["numbers"] == [1, 2, 3, 100]
        assert entity.metadata["mixed"] == ["text", 42, True]


class TestRepositoryVersionOperations:
    """Tests for version record management."""

    def test_get_version_record_returns_none_when_missing(self, repo):
        """Should return None when version record doesn't exist."""
        version = repo.get_version_record()
        assert version is None

    def test_set_version_record_with_null_lambda_version(self, repo):
        """Should handle null lambda_version correctly."""
        repo.set_version_record(
            schema_version="1.0.0",
            lambda_version=None,
            client_min_version="0.1.0",
            updated_by="test",
        )
        version = repo.get_version_record()
        assert version is not None
        assert version["schema_version"] == "1.0.0"
        assert version["lambda_version"] is None
        assert version["client_min_version"] == "0.1.0"

    def test_set_version_record_with_null_updated_by(self, repo):
        """Should handle null updated_by correctly."""
        repo.set_version_record(
            schema_version="1.0.0",
            lambda_version="0.1.0",
            client_min_version="0.1.0",
            updated_by=None,
        )
        version = repo.get_version_record()
        assert version is not None
        assert version["schema_version"] == "1.0.0"
        assert version["lambda_version"] == "0.1.0"
        assert version["updated_by"] is None


class TestRepositoryEntityValidation:
    """Tests for input validation in SyncRepository.create_entity()."""

    def test_create_entity_valid(self, repo):
        """Valid entity_id should be accepted."""
        entity = repo.create_entity("user-123", name="Test User")
        assert entity.id == "user-123"
        assert entity.name == "Test User"

    def test_create_entity_rejects_hash_in_id(self, repo):
        """Entity ID with # delimiter should be rejected."""
        with pytest.raises(InvalidIdentifierError) as exc_info:
            repo.create_entity("user#123")
        assert exc_info.value.field == "entity_id"
        assert "#" in exc_info.value.reason

    def test_create_entity_rejects_empty_id(self, repo):
        """Empty entity ID should be rejected."""
        with pytest.raises(InvalidIdentifierError) as exc_info:
            repo.create_entity("")
        assert exc_info.value.field == "entity_id"
        assert "empty" in exc_info.value.reason

    def test_create_entity_rejects_too_long_id(self, repo):
        """Entity ID exceeding max length should be rejected."""
        with pytest.raises(InvalidIdentifierError) as exc_info:
            repo.create_entity("a" * 300)
        assert exc_info.value.field == "entity_id"
        assert "length" in exc_info.value.reason

    def test_create_entity_rejects_invalid_start_char(self, repo):
        """Entity ID must start with alphanumeric."""
        with pytest.raises(InvalidIdentifierError):
            repo.create_entity("_user123")

    def test_create_entity_valid_parent_id(self, repo):
        """Valid parent_id should be accepted."""
        repo.create_entity("parent-1")
        entity = repo.create_entity("child-1", parent_id="parent-1")
        assert entity.parent_id == "parent-1"

    def test_create_entity_rejects_hash_in_parent_id(self, repo):
        """Parent ID with # delimiter should be rejected."""
        with pytest.raises(InvalidIdentifierError) as exc_info:
            repo.create_entity("child-1", parent_id="parent#123")
        assert exc_info.value.field == "parent_id"
        assert "#" in exc_info.value.reason

    def test_create_entity_rejects_empty_parent_id(self, repo):
        """Empty parent ID should be rejected (use None instead)."""
        with pytest.raises(InvalidIdentifierError) as exc_info:
            repo.create_entity("child-1", parent_id="")
        assert exc_info.value.field == "parent_id"
        assert "empty" in exc_info.value.reason

    def test_create_entity_accepts_uuid(self, repo):
        """UUID format should be accepted."""
        entity = repo.create_entity("550e8400-e29b-41d4-a716-446655440000")
        assert entity.id == "550e8400-e29b-41d4-a716-446655440000"

    def test_create_entity_accepts_api_key_format(self, repo):
        """API key format (sk-proj-xxx) should be accepted."""
        entity = repo.create_entity("sk-proj-abc123_xyz")
        assert entity.id == "sk-proj-abc123_xyz"

    def test_create_entity_accepts_email_like(self, repo):
        """Email-like format should be accepted."""
        entity = repo.create_entity("user@example.com")
        assert entity.id == "user@example.com"


class TestRepositoryNoTTLOnConfigRecords:
    """Tests verifying entity metadata and config records do NOT have TTL.

    Issue #234: Entity metadata and config are intentional configuration that
    must not be auto-deleted. Only usage snapshots and audit records have TTL.
    """

    def test_entity_metadata_has_no_ttl(self, repo):
        """Entity metadata record (SK=#META) should NOT have ttl attribute."""
        from zae_limiter import schema

        repo.create_entity(entity_id="no-ttl-meta-test", name="Test")
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_entity("default", "no-ttl-meta-test")},
                "SK": {"S": schema.sk_meta()},
            },
        )
        item = response.get("Item", {})
        assert item, "Entity metadata record should exist"
        assert "ttl" not in item, "Entity metadata should NOT have ttl attribute"

    def test_entity_config_has_no_ttl(self, repo):
        """Entity config record (SK=#CONFIG#) should NOT have ttl attribute."""
        from zae_limiter import schema
        from zae_limiter.models import Limit

        repo.create_entity(entity_id="no-ttl-config-test")
        repo.set_limits(
            entity_id="no-ttl-config-test", limits=[Limit.per_minute("rpm", 100)], resource="api"
        )
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_entity("default", "no-ttl-config-test")},
                "SK": {"S": schema.sk_config("api")},
            },
        )
        item = response.get("Item", {})
        assert item, "Entity config record should exist"
        assert "ttl" not in item, "Entity config should NOT have ttl attribute"

    def test_system_config_has_no_ttl(self, repo):
        """System config record (SYSTEM# / #CONFIG) should NOT have ttl attribute."""
        from zae_limiter import schema
        from zae_limiter.models import Limit

        repo.set_system_defaults(limits=[Limit.per_minute("rpm", 1000)])
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={"PK": {"S": schema.pk_system("default")}, "SK": {"S": schema.sk_config()}},
        )
        item = response.get("Item", {})
        assert item, "System config record should exist"
        assert "ttl" not in item, "System config should NOT have ttl attribute"

    def test_resource_config_has_no_ttl(self, repo):
        """Resource config record (RESOURCE# / #CONFIG) should NOT have ttl attribute."""
        from zae_limiter import schema
        from zae_limiter.models import Limit

        repo.set_resource_defaults(resource="gpt-4", limits=[Limit.per_minute("rpm", 500)])
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_resource("default", "gpt-4")},
                "SK": {"S": schema.sk_config()},
            },
        )
        item = response.get("Item", {})
        assert item, "Resource config record should exist"
        assert "ttl" not in item, "Resource config should NOT have ttl attribute"


class TestRepositoryAuditLogging:
    """Tests for security audit logging."""

    def test_create_entity_logs_audit_event(self, repo):
        """Creating an entity should log an audit event."""
        repo.create_entity(
            entity_id="audit-test-entity", name="Audit Test", principal="user@example.com"
        )
        events = repo.get_audit_events("audit-test-entity")
        assert len(events) == 1
        event = events[0]
        assert event.action == AuditAction.ENTITY_CREATED
        assert event.entity_id == "audit-test-entity"
        assert event.principal == "user@example.com"
        assert event.details["name"] == "Audit Test"

    def test_create_entity_logs_audit_with_auto_detected_principal(self, repo):
        """Creating an entity without explicit principal auto-detects from AWS identity."""
        repo.create_entity(entity_id="audit-test-entity-2", name="No Principal")
        events = repo.get_audit_events("audit-test-entity-2")
        assert len(events) == 1
        principal = events[0].principal
        if principal is not None:
            assert principal.startswith("arn:aws:")

    def test_delete_entity_logs_audit_event(self, repo):
        """Deleting an entity should log an audit event."""
        repo.create_entity(entity_id="to-delete")
        repo.delete_entity(entity_id="to-delete", principal="admin@example.com")
        events = repo.get_audit_events("to-delete")
        assert len(events) == 2
        delete_event = events[0]
        assert delete_event.action == AuditAction.ENTITY_DELETED
        assert delete_event.principal == "admin@example.com"
        assert "records_deleted" in delete_event.details

    def test_set_limits_logs_audit_event(self, repo):
        """Setting limits should log an audit event."""
        repo.create_entity(entity_id="limits-test")
        limits = [Limit.per_minute("rpm", 100), Limit.per_minute("tpm", 10000)]
        repo.set_limits(
            entity_id="limits-test",
            limits=limits,
            resource="gpt-4",
            principal="api-admin@example.com",
        )
        events = repo.get_audit_events("limits-test")
        assert len(events) == 2
        limits_event = events[0]
        assert limits_event.action == AuditAction.LIMITS_SET
        assert limits_event.principal == "api-admin@example.com"
        assert limits_event.resource == "gpt-4"
        assert len(limits_event.details["limits"]) == 2

    def test_delete_limits_logs_audit_event(self, repo):
        """Deleting limits should log an audit event."""
        repo.create_entity(entity_id="delete-limits-test")
        repo.set_limits(entity_id="delete-limits-test", limits=[Limit.per_minute("rpm", 100)])
        repo.delete_limits(entity_id="delete-limits-test", principal="cleanup-service")
        events = repo.get_audit_events("delete-limits-test")
        assert len(events) == 3
        delete_event = events[0]
        assert delete_event.action == AuditAction.LIMITS_DELETED
        assert delete_event.principal == "cleanup-service"

    def test_get_audit_events_pagination(self, repo):
        """Should support pagination for audit events."""
        repo.create_entity(entity_id="pagination-test")
        for i in range(5):
            repo.set_limits(
                entity_id="pagination-test",
                limits=[Limit.per_minute(f"limit-{i}", 100 * (i + 1))],
                principal=f"user-{i}",
            )
        events = repo.get_audit_events("pagination-test", limit=3)
        assert len(events) == 3
        all_events = repo.get_audit_events("pagination-test", limit=10)
        assert len(all_events) == 6

    def test_get_audit_events_empty_for_nonexistent(self, repo):
        """Should return empty list for entity with no audit events."""
        events = repo.get_audit_events("nonexistent-entity")
        assert events == []

    def test_audit_event_includes_parent_id(self, repo):
        """Audit event for child entity should include parent_id."""
        repo.create_entity(entity_id="parent-entity")
        repo.create_entity(entity_id="child-entity", parent_id="parent-entity", principal="admin")
        events = repo.get_audit_events("child-entity")
        assert len(events) == 1
        assert events[0].details["parent_id"] == "parent-entity"

    def test_audit_event_includes_metadata(self, repo):
        """Audit event should include entity metadata."""
        repo.create_entity(
            entity_id="metadata-test",
            metadata={"tier": "premium", "region": "us-west-2"},
            principal="onboarding-service",
        )
        events = repo.get_audit_events("metadata-test")
        assert len(events) == 1
        assert events[0].details["metadata"]["tier"] == "premium"
        assert events[0].details["metadata"]["region"] == "us-west-2"

    def test_create_entity_rejects_invalid_principal(self, repo):
        """Principal with # delimiter should be rejected."""
        with pytest.raises(InvalidIdentifierError) as exc_info:
            repo.create_entity(entity_id="valid-entity", principal="user#admin")
        assert exc_info.value.field == "principal"
        assert "#" in exc_info.value.reason

    def test_create_entity_rejects_empty_principal(self, repo):
        """Empty principal should be rejected (use None instead)."""
        with pytest.raises(InvalidIdentifierError) as exc_info:
            repo.create_entity(entity_id="valid-entity", principal="")
        assert exc_info.value.field == "principal"
        assert "empty" in exc_info.value.reason

    def test_create_entity_accepts_email_principal(self, repo):
        """Email-like principal should be accepted."""
        repo.create_entity(entity_id="email-principal-test", principal="admin@example.com")
        events = repo.get_audit_events("email-principal-test")
        assert events[0].principal == "admin@example.com"

    def test_create_entity_accepts_service_principal(self, repo):
        """Service name principal should be accepted."""
        repo.create_entity(entity_id="service-principal-test", principal="auth-service-v2")
        events = repo.get_audit_events("service-principal-test")
        assert events[0].principal == "auth-service-v2"

    def test_audit_event_id_is_ulid_format(self, repo):
        """Event ID should be a valid 26-character ULID."""
        repo.create_entity(entity_id="ulid-test")
        events = repo.get_audit_events("ulid-test")
        assert len(events) == 1
        event_id = events[0].event_id
        assert len(event_id) == 26
        assert event_id.isalnum()
        valid_chars = set("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
        assert all(c in valid_chars for c in event_id.upper())

    def test_audit_event_ids_are_monotonic(self, repo):
        """Multiple events should have monotonically increasing ULIDs."""
        repo.create_entity(entity_id="monotonic-test")
        for i in range(5):
            repo.set_limits(
                entity_id="monotonic-test", limits=[Limit.per_minute(f"limit-{i}", 100)]
            )
        events = repo.get_audit_events("monotonic-test", limit=10)
        event_ids = [e.event_id for e in reversed(events)]
        for i in range(1, len(event_ids)):
            assert event_ids[i] > event_ids[i - 1], (
                f"Event IDs not monotonic: {event_ids[i - 1]} >= {event_ids[i]}"
            )

    def test_get_caller_identity_handles_sts_failure(self, repo):
        """STS failures should be handled gracefully, returning None."""
        from unittest.mock import MagicMock, patch

        repo._caller_identity_fetched = False
        repo._caller_identity_arn = None
        mock_sts_client = MagicMock()
        mock_sts_client.get_caller_identity.side_effect = Exception("STS unavailable")
        mock_sts_client.__enter__ = MagicMock(return_value=mock_sts_client)
        mock_sts_client.__exit__ = MagicMock(return_value=None)
        mock_session = MagicMock()
        mock_session.client.return_value = mock_sts_client
        with patch.object(repo, "_session", mock_session):
            arn = repo._get_caller_identity_arn()
        assert arn is None
        assert repo._caller_identity_fetched is True
        assert repo._caller_identity_arn is None

    def test_get_audit_retention_days_from_stack_options(self, repo):
        """Should return audit_retention_days from stack_options if available."""
        from zae_limiter.models import StackOptions

        repo._stack_options = StackOptions(audit_retention_days=30)
        repo._audit_retention_days_cache = None
        days = repo._get_audit_retention_days()
        assert days == 30
        assert repo._audit_retention_days_cache == 30

    def test_get_audit_retention_days_from_cache(self, repo):
        """Should return cached value if available."""
        repo._audit_retention_days_cache = 45
        days = repo._get_audit_retention_days()
        assert days == 45

    def test_get_audit_retention_days_from_dynamodb(self, repo):
        """Should read from DynamoDB system config when no stack_options."""
        from zae_limiter import schema

        repo._stack_options = None
        repo._audit_retention_days_cache = None
        client = repo._get_client()
        client.update_item(
            TableName=repo.table_name,
            Key={"PK": {"S": schema.pk_system("default")}, "SK": {"S": schema.sk_config()}},
            UpdateExpression="SET audit_retention_days = :ard",
            ExpressionAttributeValues={":ard": {"N": "60"}},
        )
        days = repo._get_audit_retention_days()
        assert days == 60
        assert repo._audit_retention_days_cache == 60

    def test_get_audit_retention_days_default_when_not_set(self, repo):
        """Should return default 90 days when not configured."""
        repo._stack_options = None
        repo._audit_retention_days_cache = None
        days = repo._get_audit_retention_days()
        assert days == 90
        assert repo._audit_retention_days_cache == 90

    def test_write_audit_retention_config(self, repo):
        """Should write audit_retention_days to system config."""
        from zae_limiter import schema
        from zae_limiter.models import StackOptions

        repo._stack_options = StackOptions(audit_retention_days=14)
        repo._write_audit_retention_config()
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={"PK": {"S": schema.pk_system("default")}, "SK": {"S": schema.sk_config()}},
        )
        item = response.get("Item", {})
        assert item.get("audit_retention_days", {}).get("N") == "14"
        assert repo._audit_retention_days_cache == 14

    def test_write_audit_retention_config_noop_without_stack_options(self, repo):
        """Should be a no-op when stack_options is None."""
        repo._stack_options = None
        repo._write_audit_retention_config()

    def test_audit_event_uses_configured_ttl(self, repo):
        """Audit event TTL should be based on audit_retention_days."""
        from zae_limiter import schema
        from zae_limiter.models import StackOptions

        repo._stack_options = StackOptions(audit_retention_days=7)
        repo._audit_retention_days_cache = None
        repo.create_entity(entity_id="ttl-test")
        client = repo._get_client()
        response = client.query(
            TableName=repo.table_name,
            KeyConditionExpression="PK = :pk AND begins_with(SK, :sk_prefix)",
            ExpressionAttributeValues={
                ":pk": {"S": schema.pk_audit("default", "ttl-test")},
                ":sk_prefix": {"S": schema.SK_AUDIT},
            },
        )
        items = response.get("Items", [])
        assert len(items) >= 1
        ttl = int(items[0]["ttl"]["N"])
        now_seconds = int(repo._now_ms() / 1000)
        expected_ttl = now_seconds + 7 * 86400
        assert abs(ttl - expected_ttl) < 10


class TestRepositoryUsageSnapshots:
    """Tests for usage snapshot queries."""

    @pytest.fixture
    def repo_with_snapshots(self, repo):
        """SyncRepository pre-populated with test usage snapshots."""
        from zae_limiter import schema

        client = repo._get_client()
        snapshots_data = [
            ("entity-1", "gpt-4", "hourly", "2024-01-15T10:00:00Z", {"tpm": 1000, "rpm": 5}),
            ("entity-1", "gpt-4", "hourly", "2024-01-15T11:00:00Z", {"tpm": 2000, "rpm": 10}),
            ("entity-1", "gpt-4", "hourly", "2024-01-15T12:00:00Z", {"tpm": 1500, "rpm": 8}),
            ("entity-1", "gpt-4", "daily", "2024-01-15T00:00:00Z", {"tpm": 4500, "rpm": 23}),
            ("entity-1", "gpt-3.5", "hourly", "2024-01-15T10:00:00Z", {"tpm": 500, "rpm": 3}),
            ("entity-2", "gpt-4", "hourly", "2024-01-15T10:00:00Z", {"tpm": 3000, "rpm": 15}),
            ("entity-2", "gpt-4", "hourly", "2024-01-15T11:00:00Z", {"tpm": 2500, "rpm": 12}),
        ]
        for entity_id, resource, window_type, window_start, counters in snapshots_data:
            item = {
                "PK": {"S": schema.pk_entity("default", entity_id)},
                "SK": {"S": schema.sk_usage(resource, window_start)},
                "entity_id": {"S": entity_id},
                "resource": {"S": resource},
                "window": {"S": window_type},
                "window_start": {"S": window_start},
                "total_events": {"N": str(sum(counters.values()))},
                "GSI2PK": {"S": schema.gsi2_pk_resource("default", resource)},
                "GSI2SK": {"S": f"USAGE#{window_start}#{entity_id}"},
            }
            for name, value in counters.items():
                item[name] = {"N": str(value)}
            client.put_item(TableName=repo.table_name, Item=item)
        yield repo

    def test_get_usage_snapshots_by_entity(self, repo_with_snapshots):
        """Query snapshots for a single entity."""
        snapshots, next_key = repo_with_snapshots.get_usage_snapshots(entity_id="entity-1")
        assert len(snapshots) == 5
        assert all(s.entity_id == "entity-1" for s in snapshots)
        assert next_key is None

    def test_get_usage_snapshots_by_entity_and_resource(self, repo_with_snapshots):
        """Query snapshots for entity + resource filter."""
        snapshots, next_key = repo_with_snapshots.get_usage_snapshots(
            entity_id="entity-1", resource="gpt-4"
        )
        assert len(snapshots) == 4
        assert all(s.entity_id == "entity-1" for s in snapshots)
        assert all(s.resource == "gpt-4" for s in snapshots)

    def test_get_usage_snapshots_by_resource_gsi2(self, repo_with_snapshots):
        """Query snapshots for a resource across all entities (GSI2)."""
        snapshots, next_key = repo_with_snapshots.get_usage_snapshots(resource="gpt-4")
        assert len(snapshots) == 6
        assert all(s.resource == "gpt-4" for s in snapshots)
        entity_ids = {s.entity_id for s in snapshots}
        assert entity_ids == {"entity-1", "entity-2"}

    def test_get_usage_snapshots_filter_by_window_type(self, repo_with_snapshots):
        """Filter snapshots by window type."""
        snapshots, _ = repo_with_snapshots.get_usage_snapshots(
            entity_id="entity-1", resource="gpt-4", window_type="hourly"
        )
        assert len(snapshots) == 3
        assert all(s.window_type == "hourly" for s in snapshots)

    def test_get_usage_snapshots_filter_by_time_range(self, repo_with_snapshots):
        """Filter snapshots by start_time and end_time."""
        snapshots, _ = repo_with_snapshots.get_usage_snapshots(
            entity_id="entity-1",
            resource="gpt-4",
            start_time="2024-01-15T10:00:00Z",
            end_time="2024-01-15T11:00:00Z",
        )
        assert len(snapshots) == 2
        window_starts = {s.window_start for s in snapshots}
        assert "2024-01-15T10:00:00Z" in window_starts
        assert "2024-01-15T11:00:00Z" in window_starts

    def test_get_usage_snapshots_empty_result(self, repo_with_snapshots):
        """Query for nonexistent entity returns empty list."""
        snapshots, next_key = repo_with_snapshots.get_usage_snapshots(entity_id="nonexistent")
        assert snapshots == []
        assert next_key is None

    def test_get_usage_snapshots_requires_entity_or_resource(self, repo):
        """Should raise ValueError if neither entity_id nor resource provided."""
        with pytest.raises(ValueError, match="Either entity_id or resource"):
            repo.get_usage_snapshots()

    def test_get_usage_snapshots_pagination(self, repo_with_snapshots):
        """Test pagination with limit parameter."""
        snapshots1, next_key1 = repo_with_snapshots.get_usage_snapshots(
            entity_id="entity-1", limit=2
        )
        assert len(snapshots1) == 2
        assert next_key1 is not None
        snapshots2, next_key2 = repo_with_snapshots.get_usage_snapshots(
            entity_id="entity-1", limit=2, next_key=next_key1
        )
        assert len(snapshots2) == 2
        assert next_key2 is not None
        snapshots3, next_key3 = repo_with_snapshots.get_usage_snapshots(
            entity_id="entity-1", limit=2, next_key=next_key2
        )
        assert len(snapshots3) == 1
        assert next_key3 is None
        all_keys = [(s.resource, s.window_start) for s in snapshots1 + snapshots2 + snapshots3]
        assert len(all_keys) == len(set(all_keys))

    def test_get_usage_snapshots_counters_extracted(self, repo_with_snapshots):
        """Verify counters are correctly extracted from flat schema."""
        snapshots, _ = repo_with_snapshots.get_usage_snapshots(
            entity_id="entity-1",
            resource="gpt-4",
            window_type="hourly",
            start_time="2024-01-15T10:00:00Z",
            end_time="2024-01-15T10:00:00Z",
        )
        assert len(snapshots) == 1
        snapshot = snapshots[0]
        assert snapshot.counters == {"tpm": 1000, "rpm": 5}
        assert snapshot.total_events == 1005

    def test_get_usage_snapshots_window_end_calculated(self, repo_with_snapshots):
        """Verify window_end is correctly calculated."""
        snapshots, _ = repo_with_snapshots.get_usage_snapshots(
            entity_id="entity-1",
            resource="gpt-4",
            window_type="hourly",
            start_time="2024-01-15T10:00:00Z",
            end_time="2024-01-15T10:00:00Z",
        )
        assert len(snapshots) == 1
        snapshot = snapshots[0]
        assert snapshot.window_start == "2024-01-15T10:00:00Z"
        assert "10:59:59" in snapshot.window_end

    def test_get_usage_summary_aggregation(self, repo_with_snapshots):
        """Test summary aggregation across snapshots."""
        summary = repo_with_snapshots.get_usage_summary(
            entity_id="entity-1", resource="gpt-4", window_type="hourly"
        )
        assert summary.snapshot_count == 3
        assert summary.total["tpm"] == 4500
        assert summary.total["rpm"] == 23
        assert summary.average["tpm"] == 1500.0
        assert abs(summary.average["rpm"] - 7.666666666666667) < 0.001
        assert summary.min_window_start == "2024-01-15T10:00:00Z"
        assert summary.max_window_start == "2024-01-15T12:00:00Z"

    def test_get_usage_summary_empty(self, repo_with_snapshots):
        """Summary for nonexistent entity returns zeros."""
        summary = repo_with_snapshots.get_usage_summary(entity_id="nonexistent")
        assert summary.snapshot_count == 0
        assert summary.total == {}
        assert summary.average == {}
        assert summary.min_window_start is None
        assert summary.max_window_start is None

    def test_get_usage_summary_requires_entity_or_resource(self, repo):
        """Should raise ValueError if neither entity_id nor resource provided."""
        with pytest.raises(ValueError, match="Either entity_id or resource"):
            repo.get_usage_summary()

    def test_get_usage_snapshots_skips_malformed_items(self, repo):
        """Malformed snapshot items are skipped during deserialization."""
        from zae_limiter import schema

        client = repo._get_client()
        client.put_item(
            TableName=repo.table_name,
            Item={
                "PK": {"S": schema.pk_entity("default", "test-malformed")},
                "SK": {"S": schema.sk_usage("gpt-4", "2024-01-15T10:00:00Z")},
                "window": {"S": "hourly"},
                "tpm": {"N": "100"},
            },
        )
        client.put_item(
            TableName=repo.table_name,
            Item={
                "PK": {"S": schema.pk_entity("default", "test-malformed")},
                "SK": {"S": schema.sk_usage("gpt-4", "2024-01-15T11:00:00Z")},
                "entity_id": {"S": "test-malformed"},
                "resource": {"S": "gpt-4"},
                "window": {"S": "hourly"},
                "window_start": {"S": "2024-01-15T11:00:00Z"},
                "tpm": {"N": "200"},
                "total_events": {"N": "10"},
                "GSI2PK": {"S": "default/RESOURCE#gpt-4"},
                "GSI2SK": {"S": "USAGE#2024-01-15T11:00:00Z#test-malformed"},
            },
        )
        snapshots, _ = repo.get_usage_snapshots(entity_id="test-malformed")
        assert len(snapshots) == 1
        assert snapshots[0].entity_id == "test-malformed"
        assert snapshots[0].window_start == "2024-01-15T11:00:00Z"

    @pytest.mark.parametrize(
        "window_type,window_start,expected_end_contains",
        [
            ("hourly", "2024-01-15T10:00:00Z", "10:59:59"),
            ("daily", "2024-01-15T00:00:00Z", "23:59:59"),
            ("monthly", "2024-01-01T00:00:00Z", "2024-01-31"),
            ("monthly", "2024-12-01T00:00:00Z", "2024-12-31"),
            ("monthly", "2024-02-01T00:00:00Z", "2024-02-29"),
            ("monthly", "2023-02-01T00:00:00Z", "2023-02-28"),
        ],
    )
    def test_get_usage_snapshots_window_end_by_type(
        self, repo, window_type, window_start, expected_end_contains
    ):
        """Test window_end calculation for all supported window types."""
        from zae_limiter import schema

        client = repo._get_client()
        entity_id = f"test-{window_type}-{window_start[:10]}"
        client.put_item(
            TableName=repo.table_name,
            Item={
                "PK": {"S": schema.pk_entity("default", entity_id)},
                "SK": {"S": schema.sk_usage("gpt-4", window_start)},
                "entity_id": {"S": entity_id},
                "resource": {"S": "gpt-4"},
                "window": {"S": window_type},
                "window_start": {"S": window_start},
                "tpm": {"N": "1000"},
                "total_events": {"N": "10"},
                "GSI2PK": {"S": "default/RESOURCE#gpt-4"},
                "GSI2SK": {"S": f"USAGE#{window_start}#{entity_id}"},
            },
        )
        snapshots, _ = repo.get_usage_snapshots(entity_id=entity_id)
        assert len(snapshots) == 1
        assert snapshots[0].window_type == window_type
        assert expected_end_contains in snapshots[0].window_end

    def test_get_usage_snapshots_unknown_window_type(self, repo):
        """Test window_end for unknown window type returns window_start."""
        from zae_limiter import schema

        client = repo._get_client()
        client.put_item(
            TableName=repo.table_name,
            Item={
                "PK": {"S": schema.pk_entity("default", "unknown-window")},
                "SK": {"S": schema.sk_usage("gpt-4", "2024-01-15T10:00:00Z")},
                "entity_id": {"S": "unknown-window"},
                "resource": {"S": "gpt-4"},
                "window": {"S": "unknown"},
                "window_start": {"S": "2024-01-15T10:00:00Z"},
                "tpm": {"N": "100"},
                "total_events": {"N": "5"},
                "GSI2PK": {"S": "default/RESOURCE#gpt-4"},
                "GSI2SK": {"S": "USAGE#2024-01-15T10:00:00Z#unknown-window"},
            },
        )
        snapshots, _ = repo.get_usage_snapshots(entity_id="unknown-window")
        assert len(snapshots) == 1
        assert snapshots[0].window_end == "2024-01-15T10:00:00Z"

    def test_get_usage_snapshots_invalid_window_start(self, repo):
        """Test window_end for invalid window_start returns window_start."""
        from zae_limiter import schema

        client = repo._get_client()
        client.put_item(
            TableName=repo.table_name,
            Item={
                "PK": {"S": schema.pk_entity("default", "invalid-date")},
                "SK": {"S": schema.sk_usage("gpt-4", "invalid-date")},
                "entity_id": {"S": "invalid-date"},
                "resource": {"S": "gpt-4"},
                "window": {"S": "hourly"},
                "window_start": {"S": "invalid-date"},
                "tpm": {"N": "100"},
                "total_events": {"N": "5"},
                "GSI2PK": {"S": "default/RESOURCE#gpt-4"},
                "GSI2SK": {"S": "USAGE#invalid-date#invalid-date"},
            },
        )
        snapshots, _ = repo.get_usage_snapshots(entity_id="invalid-date")
        assert len(snapshots) == 1
        assert snapshots[0].window_end == "invalid-date"


class TestRepositoryDeprecation:
    """Tests for deprecated SyncRepository methods."""

    def test_create_stack_emits_deprecation_warning(self):
        """create_stack() should emit DeprecationWarning pointing to ensure_infrastructure()."""
        from unittest.mock import MagicMock, patch

        repo = SyncRepository(name="test-deprecation", region="us-east-1")
        with patch("zae_limiter.infra.sync_stack_manager.SyncStackManager") as mock_manager_class:
            mock_manager = MagicMock()
            mock_manager.__enter__ = MagicMock(return_value=mock_manager)
            mock_manager.__exit__ = MagicMock(return_value=None)
            mock_manager.create_stack = MagicMock(return_value={"StackId": "test"})
            mock_manager_class.return_value = mock_manager
            with patch.object(repo, "_write_audit_retention_config", MagicMock()):
                with pytest.warns(DeprecationWarning, match="create_stack.*deprecated"):
                    from zae_limiter import StackOptions

                    repo.create_stack(stack_options=StackOptions())
        repo.close()

    def test_create_stack_deprecation_message_mentions_ensure_infrastructure(self):
        """Deprecation message should direct users to ensure_infrastructure()."""
        import warnings
        from unittest.mock import MagicMock, patch

        repo = SyncRepository(name="test-deprecation-msg", region="us-east-1")
        with patch("zae_limiter.infra.sync_stack_manager.SyncStackManager") as mock_manager_class:
            mock_manager = MagicMock()
            mock_manager.__enter__ = MagicMock(return_value=mock_manager)
            mock_manager.__exit__ = MagicMock(return_value=None)
            mock_manager.create_stack = MagicMock(return_value={"StackId": "test"})
            mock_manager_class.return_value = mock_manager
            with patch.object(repo, "_write_audit_retention_config", MagicMock()):
                with warnings.catch_warnings(record=True) as w:
                    warnings.simplefilter("always")
                    from zae_limiter import StackOptions

                    repo.create_stack(stack_options=StackOptions())
                    create_stack_warnings = [
                        x
                        for x in w
                        if issubclass(x.category, DeprecationWarning)
                        and "create_stack" in str(x.message)
                    ]
                    assert len(create_stack_warnings) == 1
                    msg = str(create_stack_warnings[0].message)
                    assert "ensure_infrastructure" in msg
                    assert "v1.0.0" in msg
        repo.close()

    def test_create_stack_without_options_uses_constructor_options(self):
        """create_stack() without args should use constructor-provided stack_options."""
        import warnings
        from unittest.mock import MagicMock, patch

        from zae_limiter import StackOptions

        repo = SyncRepository(
            name="test-constructor-opts",
            region="us-east-1",
            stack_options=StackOptions(lambda_memory=512),
        )
        with patch("zae_limiter.infra.sync_stack_manager.SyncStackManager") as mock_manager_class:
            mock_manager = MagicMock()
            mock_manager.__enter__ = MagicMock(return_value=mock_manager)
            mock_manager.__exit__ = MagicMock(return_value=None)
            mock_manager.create_stack = MagicMock(return_value={"StackId": "test"})
            mock_manager_class.return_value = mock_manager
            with patch.object(repo, "_write_audit_retention_config", MagicMock()):
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore", DeprecationWarning)
                    repo.create_stack()
                mock_manager.create_stack.assert_called_once()
                call_kwargs = mock_manager.create_stack.call_args[1]
                assert call_kwargs["stack_options"].lambda_memory == 512
        repo.close()


class TestGSI3EntityConfigIndex:
    """Tests for GSI3 sparse index for entity config queries."""

    def test_set_limits_writes_gsi3_attributes(self, repo):
        """set_limits should write GSI3PK and GSI3SK for entity-level configs."""
        repo.create_entity("user-123")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-123", limits, resource="gpt-4")
        client = repo._get_client()
        from zae_limiter.schema import gsi3_pk_entity_config, gsi3_sk_entity, pk_entity, sk_config

        response = client.get_item(
            TableName=repo.table_name,
            Key={"PK": {"S": pk_entity("default", "user-123")}, "SK": {"S": sk_config("gpt-4")}},
        )
        item = response.get("Item")
        assert item is not None
        assert "GSI3PK" in item
        assert item["GSI3PK"]["S"] == gsi3_pk_entity_config("default", "gpt-4")
        assert "GSI3SK" in item
        assert item["GSI3SK"]["S"] == gsi3_sk_entity("user-123")

    def test_delete_limits_removes_from_gsi3(self, repo):
        """delete_limits removes entity from GSI3 (via DeleteItem)."""
        repo.create_entity("user-123")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-123", limits, resource="gpt-4")
        entities, _ = repo.list_entities_with_custom_limits("gpt-4")
        assert "user-123" in entities
        repo.delete_limits("user-123", resource="gpt-4")
        entities, _ = repo.list_entities_with_custom_limits("gpt-4")
        assert "user-123" not in entities

    def test_system_config_not_in_gsi3(self, repo):
        """System config should not have GSI3 attributes."""
        limits = [Limit.per_minute("rpm", 500)]
        repo.set_system_defaults(limits)
        client = repo._get_client()
        from zae_limiter.schema import pk_system, sk_config

        response = client.get_item(
            TableName=repo.table_name,
            Key={"PK": {"S": pk_system("default")}, "SK": {"S": sk_config()}},
        )
        item = response.get("Item")
        assert item is not None
        assert "GSI3PK" not in item
        assert "GSI3SK" not in item

    def test_resource_config_not_in_gsi3(self, repo):
        """Resource config should not have GSI3 attributes."""
        limits = [Limit.per_minute("rpm", 500)]
        repo.set_resource_defaults("gpt-4", limits)
        client = repo._get_client()
        from zae_limiter.schema import pk_resource, sk_config

        response = client.get_item(
            TableName=repo.table_name,
            Key={"PK": {"S": pk_resource("default", "gpt-4")}, "SK": {"S": sk_config()}},
        )
        item = response.get("Item")
        assert item is not None
        assert "GSI3PK" not in item
        assert "GSI3SK" not in item

    def test_list_entities_with_custom_limits(self, repo):
        """list_entities_with_custom_limits returns correct entities."""
        repo.create_entity("user-1")
        repo.create_entity("user-2")
        repo.create_entity("user-3")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-1", limits, resource="gpt-4")
        repo.set_limits("user-2", limits, resource="gpt-4")
        repo.set_limits("user-3", limits, resource="claude-3")
        entities, cursor = repo.list_entities_with_custom_limits("gpt-4")
        assert set(entities) == {"user-1", "user-2"}
        assert cursor is None
        entities, cursor = repo.list_entities_with_custom_limits("claude-3")
        assert set(entities) == {"user-3"}
        assert cursor is None
        entities, cursor = repo.list_entities_with_custom_limits("nonexistent")
        assert entities == []
        assert cursor is None

    def test_list_entities_with_custom_limits_pagination(self, repo):
        """list_entities_with_custom_limits should support pagination."""
        limits = [Limit.per_minute("rpm", 1000)]
        for i in range(5):
            repo.create_entity(f"user-{i}")
            repo.set_limits(f"user-{i}", limits, resource="gpt-4")
        entities, cursor = repo.list_entities_with_custom_limits("gpt-4", limit=2)
        assert len(entities) == 2
        if cursor is not None:
            more_entities, _ = repo.list_entities_with_custom_limits("gpt-4", cursor=cursor)
            all_entities = set(entities + more_entities)
            assert len(all_entities) >= 2


class TestEntityConfigRegistry:
    """Tests for entity config registry (issue #288)."""

    def test_set_limits_increments_registry_on_new(self, repo):
        """set_limits should increment registry count for NEW entity configs."""
        repo.create_entity("user-1")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-1", limits, resource="gpt-4")
        resources = repo.list_resources_with_entity_configs()
        assert "gpt-4" in resources

    def test_set_limits_no_increment_on_update(self, repo):
        """set_limits should NOT increment registry count on UPDATE."""
        repo.create_entity("user-1")
        limits1 = [Limit.per_minute("rpm", 1000)]
        limits2 = [Limit.per_minute("rpm", 2000)]
        repo.set_limits("user-1", limits1, resource="gpt-4")
        repo.set_limits("user-1", limits2, resource="gpt-4")
        stored = repo.get_limits("user-1", resource="gpt-4")
        assert stored[0].capacity == 2000
        from zae_limiter import schema

        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_system("default")},
                "SK": {"S": schema.sk_entity_config_resources()},
            },
        )
        item = response.get("Item", {})
        count = int(item.get("gpt-4", {}).get("N", "0"))
        assert count == 1

    def test_delete_limits_decrements_registry(self, repo):
        """delete_limits should decrement registry count."""
        repo.create_entity("user-1")
        repo.create_entity("user-2")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-1", limits, resource="gpt-4")
        repo.set_limits("user-2", limits, resource="gpt-4")
        resources = repo.list_resources_with_entity_configs()
        assert "gpt-4" in resources
        repo.delete_limits("user-1", resource="gpt-4")
        resources = repo.list_resources_with_entity_configs()
        assert "gpt-4" in resources
        repo.delete_limits("user-2", resource="gpt-4")
        resources = repo.list_resources_with_entity_configs()
        assert "gpt-4" not in resources

    def test_delete_limits_nonexistent_config_is_silent(self, repo):
        """delete_limits silently succeeds when config doesn't exist."""
        repo.create_entity("user-1")
        repo.delete_limits("user-1", resource="gpt-4")
        resources = repo.list_resources_with_entity_configs()
        assert "gpt-4" not in resources

    def test_list_resources_with_entity_configs_empty(self, repo):
        """list_resources_with_entity_configs returns empty list when no configs exist."""
        resources = repo.list_resources_with_entity_configs()
        assert resources == []

    def test_list_resources_with_entity_configs_multiple_resources(self, repo):
        """list_resources_with_entity_configs returns all resources with entity configs."""
        repo.create_entity("user-1")
        repo.create_entity("user-2")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-1", limits, resource="gpt-4")
        repo.set_limits("user-2", limits, resource="claude-3")
        resources = repo.list_resources_with_entity_configs()
        assert set(resources) == {"gpt-4", "claude-3"}

    def test_list_resources_with_entity_configs_sorted(self, repo):
        """list_resources_with_entity_configs returns sorted list."""
        repo.create_entity("user-1")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-1", limits, resource="zebra")
        repo.set_limits("user-1", limits, resource="alpha")
        repo.set_limits("user-1", limits, resource="middle")
        resources = repo.list_resources_with_entity_configs()
        assert resources == ["alpha", "middle", "zebra"]

    def test_set_limits_reraises_non_conditional_transaction_error(self, repo):
        """set_limits re-raises transaction errors that aren't ConditionalCheckFailed."""
        from unittest.mock import MagicMock, patch

        from botocore.exceptions import ClientError

        repo.create_entity("user-1")
        limits = [Limit.per_minute("rpm", 1000)]
        error_response = {
            "Error": {"Code": "TransactionCanceledException"},
            "CancellationReasons": [{"Code": "ValidationError"}],
        }
        mock_client = MagicMock()
        mock_client.transact_write_items = MagicMock(
            side_effect=ClientError(error_response, "TransactWriteItems")
        )
        with patch.object(repo, "_get_client", return_value=mock_client):
            with pytest.raises(ClientError) as exc_info:
                repo.set_limits("user-1", limits, resource="gpt-4")
            assert exc_info.value.response["Error"]["Code"] == "TransactionCanceledException"

    def test_set_limits_reraises_non_transaction_error(self, repo):
        """set_limits re-raises non-transaction ClientErrors."""
        from unittest.mock import MagicMock, patch

        from botocore.exceptions import ClientError

        repo.create_entity("user-1")
        limits = [Limit.per_minute("rpm", 1000)]
        error_response = {"Error": {"Code": "InternalServerError"}}
        mock_client = MagicMock()
        mock_client.transact_write_items = MagicMock(
            side_effect=ClientError(error_response, "TransactWriteItems")
        )
        with patch.object(repo, "_get_client", return_value=mock_client):
            with pytest.raises(ClientError) as exc_info:
                repo.set_limits("user-1", limits, resource="gpt-4")
            assert exc_info.value.response["Error"]["Code"] == "InternalServerError"

    def test_delete_limits_reraises_non_conditional_transaction_error(self, repo):
        """delete_limits re-raises transaction errors that aren't ConditionalCheckFailed."""
        from unittest.mock import MagicMock, patch

        from botocore.exceptions import ClientError

        repo.create_entity("user-1")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-1", limits, resource="gpt-4")
        error_response = {
            "Error": {"Code": "TransactionCanceledException"},
            "CancellationReasons": [{"Code": "ValidationError"}],
        }
        mock_client = MagicMock()
        mock_client.transact_write_items = MagicMock(
            side_effect=ClientError(error_response, "TransactWriteItems")
        )
        with patch.object(repo, "_get_client", return_value=mock_client):
            with pytest.raises(ClientError) as exc_info:
                repo.delete_limits("user-1", resource="gpt-4")
            assert exc_info.value.response["Error"]["Code"] == "TransactionCanceledException"

    def test_delete_limits_reraises_non_transaction_error(self, repo):
        """delete_limits re-raises non-transaction ClientErrors."""
        from unittest.mock import MagicMock, patch

        from botocore.exceptions import ClientError

        repo.create_entity("user-1")
        limits = [Limit.per_minute("rpm", 1000)]
        repo.set_limits("user-1", limits, resource="gpt-4")
        error_response = {"Error": {"Code": "InternalServerError"}}
        mock_client = MagicMock()
        mock_client.transact_write_items = MagicMock(
            side_effect=ClientError(error_response, "TransactWriteItems")
        )
        with patch.object(repo, "_get_client", return_value=mock_client):
            with pytest.raises(ClientError) as exc_info:
                repo.delete_limits("user-1", resource="gpt-4")
            assert exc_info.value.response["Error"]["Code"] == "InternalServerError"

    def test_cleanup_registry_reraises_non_conditional_error(self, repo):
        """_cleanup_entity_config_registry re-raises non-ConditionalCheckFailedException."""
        from unittest.mock import MagicMock, patch

        from botocore.exceptions import ClientError

        error_response = {"Error": {"Code": "InternalServerError"}}
        mock_client = MagicMock()
        mock_client.update_item = MagicMock(side_effect=ClientError(error_response, "UpdateItem"))
        with patch.object(repo, "_get_client", return_value=mock_client):
            with pytest.raises(ClientError) as exc_info:
                repo._cleanup_entity_config_registry("gpt-4")
            assert exc_info.value.response["Error"]["Code"] == "InternalServerError"


class TestRepositoryEntityDuplicates:
    """Tests for duplicate entity creation handling."""

    def test_create_entity_raises_entity_exists_on_duplicate(self, repo):
        """Creating an entity that already exists should raise EntityExistsError."""
        repo.create_entity("existing-entity", name="Original")
        with pytest.raises(EntityExistsError) as exc_info:
            repo.create_entity("existing-entity", name="Duplicate")
        assert exc_info.value.entity_id == "existing-entity"

    def test_delete_entity_noop_for_nonexistent(self, repo):
        """Deleting a nonexistent entity should be a no-op (no error)."""
        repo.delete_entity("nonexistent-entity")
        entity = repo.get_entity("nonexistent-entity")
        assert entity is None


class TestRepositoryTableOperations:
    """Tests for table-level operations."""

    def test_delete_table_ignores_nonexistent(self, repo):
        """delete_table should not raise when table doesn't exist."""
        repo.delete_table()
        repo.delete_table()


class TestRepositoryPing:
    """Tests for ping connectivity check."""

    def test_ping_returns_true(self, repo):
        """ping should return True when table is reachable."""
        result = repo.ping()
        assert result is True

    def test_ping_returns_false_on_error(self, repo):
        """ping should return False when DynamoDB is unreachable."""
        mock_client = MagicMock()
        mock_client.get_item = MagicMock(
            side_effect=ClientError(
                {"Error": {"Code": "ServiceUnavailable", "Message": "down"}}, "GetItem"
            )
        )
        with patch.object(repo, "_get_client", return_value=mock_client):
            result = repo.ping()
        assert result is False


class TestCompositeLimitConfigConvenience:
    """Tests for convenience methods on composite limit configs."""

    def test_get_system_limits_convenience(self, repo):
        """get_system_limits should return only limits, not on_unavailable."""
        limits = [Limit.per_minute("rpm", 500), Limit.per_minute("tpm", 50000)]
        repo.set_system_defaults(limits, on_unavailable="allow")
        result = repo.get_system_limits()
        assert len(result) == 2
        limit_map = {lim.name: lim for lim in result}
        assert limit_map["rpm"].capacity == 500
        assert limit_map["tpm"].capacity == 50000


class TestRepositoryAuditPagination:
    """Tests for audit event pagination with start_event_id."""

    def test_get_audit_events_with_start_event_id(self, repo):
        """get_audit_events with start_event_id returns events after that ID."""
        repo.create_entity(entity_id="pagination-cursor-test")
        for i in range(5):
            repo.set_limits(
                entity_id="pagination-cursor-test",
                limits=[Limit.per_minute(f"limit-{i}", 100 * (i + 1))],
                principal=f"user-{i}",
            )
        all_events = repo.get_audit_events("pagination-cursor-test", limit=10)
        assert len(all_events) == 6
        middle_event_id = all_events[2].event_id
        remaining_events = repo.get_audit_events(
            "pagination-cursor-test", limit=10, start_event_id=middle_event_id
        )
        assert len(remaining_events) > 0
        remaining_ids = {e.event_id for e in remaining_events}
        assert middle_event_id not in remaining_ids


class TestRepositoryAuditResourceEntityId:
    """Tests for audit logging entity_id for resource-level operations."""

    def test_delete_resource_defaults_audit_resource_entity_id(self, repo):
        """delete_resource_defaults should log audit with $RESOURCE:{name} entity_id."""
        limits = [Limit.per_minute("rpm", 100)]
        repo.set_resource_defaults("gpt-4", limits, principal="admin")
        repo.delete_resource_defaults("gpt-4", principal="cleanup")
        events = repo.get_audit_events("$RESOURCE:gpt-4")
        assert len(events) >= 2
        delete_event = events[0]
        assert delete_event.action == AuditAction.LIMITS_DELETED
        assert delete_event.principal == "cleanup"


class TestRepositoryDeserializationEdgeCases:
    """Tests for edge cases in DynamoDB deserialization."""

    def test_deserialize_value_unknown_type_returns_none(self, repo):
        """_deserialize_value should return None for unknown DynamoDB types."""
        result = repo._deserialize_value({"UNKNOWN_TYPE": "some_value"})
        assert result is None

    def test_deserialize_composite_bucket_with_total_consumed(self, repo):
        """_deserialize_bucket should extract total_consumed_milli from composite item."""
        now_ms = 1700000000000
        limits = [Limit.per_minute("rpm", 100)]
        state = BucketState.from_limit("e1", "gpt-4", limits[0], now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        consumed = {"rpm": 5000}
        adjust_item = repo.build_composite_adjust("e1", "gpt-4", consumed)
        if adjust_item:
            repo.transact_write([adjust_item])
        bucket = repo.get_bucket("e1", "gpt-4", "rpm")
        assert bucket is not None
        assert bucket.total_consumed_milli is not None
        assert bucket.total_consumed_milli == 5000


class TestSpeculativeConsume:
    """Tests for speculative_consume method."""

    def test_speculative_success(self, repo):
        """Speculative consume succeeds when tokens available."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1000)]
        state = BucketState.from_limit("e1", "gpt-4", limits[0], now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 10})
        assert result.success is True
        assert len(result.buckets) == 2
        bucket_names = {b.limit_name for b in result.buckets}
        assert "rpm" in bucket_names

    def test_speculative_failure_insufficient_tokens(self, repo):
        """Speculative consume fails when tokens insufficient."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 10)]
        state = BucketState.from_limit("e1", "gpt-4", limits[0], now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 10})
        assert result.success is True
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 5})
        assert result.success is False

    def test_speculative_missing_item(self, repo):
        """Speculative consume fails when item doesn't exist."""
        result = repo.speculative_consume("nonexistent", "gpt-4", {"rpm": 1})
        assert result.success is False
        assert result.old_buckets is None
        assert result.failure_reason == SpeculativeFailureReason.BUCKET_MISSING

    def test_speculative_failure_reason_app_limit_exhausted(self, repo):
        """Failure reason is APP_LIMIT_EXHAUSTED when user limit exhausted but wcu ok."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 10)]
        state = BucketState.from_limit("e1", "gpt-4", limits[0], now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 10})
        assert result.success is True
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 5})
        assert result.success is False
        assert result.failure_reason == SpeculativeFailureReason.APP_LIMIT_EXHAUSTED

    def test_speculative_failure_reason_both_exhausted(self, repo):
        """Failure reason is BOTH_EXHAUSTED when both wcu and app limit exhausted."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1)]
        state = BucketState.from_limit("e1", "gpt-4", limits[0], now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
        assert result.success is True
        for _ in range(999):
            repo.speculative_consume("e1", "gpt-4", {"rpm": 0})
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
        assert result.success is False
        assert result.failure_reason == SpeculativeFailureReason.BOTH_EXHAUSTED

    def test_speculative_with_ttl(self, repo):
        """Speculative consume handles TTL correctly."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1000)]
        state = BucketState.from_limit("e1", "gpt-4", limits[0], now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 10}, ttl_seconds=3600)
        assert result.success is True
        assert len(result.buckets) == 2

    def test_speculative_cascade_parent_id(self, repo):
        """Speculative consume returns cascade/parent_id from item."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1000)]
        state = BucketState.from_limit("e1", "gpt-4", limits[0], now_ms)
        put_item = repo.build_composite_create(
            "e1", "gpt-4", [state], now_ms, cascade=True, parent_id="parent-1"
        )
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
        assert result.success is True
        assert result.cascade is True
        assert result.parent_id == "parent-1"

    def test_speculative_non_condition_error_reraises(self, repo):
        """Non-ConditionalCheckFailed errors are re-raised."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1000)]
        state = BucketState.from_limit("e1", "gpt-4", limits[0], now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        client = repo._get_client()
        original = client.update_item

        def failing_update(*args, **kwargs):
            raise ClientError(
                {"Error": {"Code": "InternalServerError", "Message": "boom"}}, "UpdateItem"
            )

        client.update_item = failing_update
        try:
            with pytest.raises(ClientError) as exc_info:
                repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
            assert exc_info.value.response["Error"]["Code"] == "InternalServerError"
        finally:
            client.update_item = original


class TestCompositeNormalGuard:
    """Tests for tk >= floor guard in build_composite_normal."""

    def test_normal_write_rejects_when_speculative_drained_tokens(self, repo):
        """build_composite_normal rejects when concurrent speculative drained tk.

        Sequence:
        1. Create bucket: tk=100_000 (100 rpm), rf=T1
        2. Read bucket (simulating slow path read): see tk=100_000, rf=T1
        3. Concurrent speculative_consume: ADD tk:-80_000 → tk=20_000, rf unchanged
        4. build_composite_normal with expected_rf=T1, consume=50_000, refill=0
           → rf lock passes (T1==T1), but tk guard catches it:
             tk(20_000) < floor(50_000) → ConditionalCheckFailedException
        """
        now_ms = int(time.time() * 1000)
        limit = Limit.per_minute("rpm", 100)
        state = BucketState.from_limit("e1", "gpt-4", limit, now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        buckets = repo.get_buckets("e1", resource="gpt-4")
        assert len(buckets) == 1
        rpm_bucket = next(b for b in buckets if b.limit_name == "rpm")
        original_rf = rpm_bucket.last_refill_ms
        spec_result = repo.speculative_consume("e1", "gpt-4", {"rpm": 80})
        assert spec_result.success is True
        normal_item = repo.build_composite_normal(
            entity_id="e1",
            resource="gpt-4",
            consumed={"rpm": 50000},
            refill_amounts={"rpm": 0},
            now_ms=now_ms,
            expected_rf=original_rf,
        )
        with pytest.raises(ClientError) as exc_info:
            repo.transact_write([normal_item])
        assert "ConditionalCheckFailedException" in str(exc_info.value)

    def test_normal_write_allows_when_refill_covers_consumption(self, repo):
        """build_composite_normal allows when refill >= consumed (net positive).

        When refill >= consumed, the tk floor is 0 and the guard is a no-op.
        """
        now_ms = int(time.time() * 1000)
        limit = Limit.per_minute("rpm", 100)
        state = BucketState.from_limit("e1", "gpt-4", limit, now_ms)
        put_item = repo.build_composite_create("e1", "gpt-4", [state], now_ms)
        repo.transact_write([put_item])
        buckets = repo.get_buckets("e1", resource="gpt-4")
        rpm_bucket = next(b for b in buckets if b.limit_name == "rpm")
        original_rf = rpm_bucket.last_refill_ms
        spec_result = repo.speculative_consume("e1", "gpt-4", {"rpm": 80})
        assert spec_result.success is True
        normal_item = repo.build_composite_normal(
            entity_id="e1",
            resource="gpt-4",
            consumed={"rpm": 10000},
            refill_amounts={"rpm": 50000},
            now_ms=now_ms,
            expected_rf=original_rf,
        )
        repo.transact_write([normal_item])
        buckets_after = repo.get_buckets("e1", resource="gpt-4")
        rpm_bucket = [b for b in buckets_after if b.limit_name == "rpm"][0]
        assert rpm_bucket.tokens_milli == 60000


class TestGSI4Attributes:
    """Test GSI4PK/GSI4SK on all creation paths."""

    def test_create_entity_sets_gsi4(self, repo):
        """create_entity() sets GSI4PK/GSI4SK on entity metadata."""
        from zae_limiter import schema

        repo.create_entity(entity_id="gsi4-entity", name="Test")
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_entity("default", "gsi4-entity")},
                "SK": {"S": schema.sk_meta()},
            },
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == schema.pk_entity("default", "gsi4-entity")

    def test_build_composite_create_sets_gsi4(self, repo):
        """build_composite_create() sets GSI4PK/GSI4SK on bucket items."""
        from zae_limiter import schema
        from zae_limiter.models import Limit

        repo.create_entity(entity_id="gsi4-bucket")
        limits = [Limit.per_minute("rpm", 100)]
        now_ms = int(time.time() * 1000)
        states = [BucketState.from_limit("gsi4-bucket", "api", lim, now_ms) for lim in limits]
        create_item = repo.build_composite_create(
            entity_id="gsi4-bucket", resource="api", states=states, now_ms=now_ms
        )
        repo.transact_write([create_item])
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_bucket("default", "gsi4-bucket", "api", 0)},
                "SK": {"S": schema.sk_state()},
            },
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == "BUCKET#gsi4-bucket#api#0"

    def test_set_limits_sets_gsi4_on_config(self, repo):
        """set_limits() sets GSI4PK/GSI4SK on entity config item."""
        from zae_limiter import schema
        from zae_limiter.models import Limit

        repo.create_entity(entity_id="gsi4-config")
        repo.set_limits(
            entity_id="gsi4-config", limits=[Limit.per_minute("rpm", 100)], resource="api"
        )
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_entity("default", "gsi4-config")},
                "SK": {"S": sk_config("api")},
            },
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == schema.pk_entity("default", "gsi4-config")

    def test_set_limits_sets_gsi4_on_entity_config_resources(self, repo):
        """set_limits() sets GSI4PK/GSI4SK on entity config resources registry."""
        from zae_limiter import schema

        repo.create_entity(entity_id="gsi4-ecr")
        repo.set_limits(entity_id="gsi4-ecr", limits=[Limit.per_minute("rpm", 100)], resource="api")
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_system("default")},
                "SK": {"S": schema.sk_entity_config_resources()},
            },
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == schema.pk_system("default")

    def test_set_resource_defaults_sets_gsi4_on_config(self, repo):
        """set_resource_defaults() sets GSI4PK/GSI4SK on resource config."""
        from zae_limiter import schema

        repo.set_resource_defaults(resource="gpt-4", limits=[Limit.per_minute("rpm", 100)])
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={"PK": {"S": schema.pk_resource("default", "gpt-4")}, "SK": {"S": sk_config()}},
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == schema.pk_resource("default", "gpt-4")

    def test_set_resource_defaults_sets_gsi4_on_resource_registry(self, repo):
        """set_resource_defaults() sets GSI4PK/GSI4SK on resource list."""
        from zae_limiter import schema

        repo.set_resource_defaults(resource="gpt-4", limits=[Limit.per_minute("rpm", 100)])
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={"PK": {"S": schema.pk_system("default")}, "SK": {"S": schema.sk_resources()}},
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == schema.pk_system("default")

    def test_set_system_defaults_sets_gsi4(self, repo):
        """set_system_defaults() sets GSI4PK/GSI4SK on system config."""
        from zae_limiter import schema

        repo.set_system_defaults(limits=[Limit.per_minute("rpm", 100)])
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={"PK": {"S": schema.pk_system("default")}, "SK": {"S": sk_config()}},
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == schema.pk_system("default")

    def test_set_version_record_sets_gsi4(self, repo):
        """set_version_record() sets GSI4PK/GSI4SK on version record using RESERVED_NAMESPACE."""
        from zae_limiter import schema

        repo.set_version_record(schema_version="1.0.0")
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_system(schema.RESERVED_NAMESPACE)},
                "SK": {"S": schema.sk_version()},
            },
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == schema.RESERVED_NAMESPACE
        assert item["GSI4SK"]["S"] == schema.pk_system(schema.RESERVED_NAMESPACE)

    def test_log_audit_event_sets_gsi4(self, repo):
        """_log_audit_event() sets GSI4PK/GSI4SK on audit records."""
        from zae_limiter import schema

        event = repo._log_audit_event(
            action="test_action", entity_id="audit-entity", principal="test-user"
        )
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_audit("default", "audit-entity")},
                "SK": {"S": schema.sk_audit(event.event_id)},
            },
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == schema.pk_audit("default", "audit-entity")

    def test_speculative_consume_does_not_set_gsi4(self, repo):
        """speculative_consume() does NOT set GSI4 (update path, not creation)."""
        from zae_limiter import schema
        from zae_limiter.models import Limit

        repo.create_entity(entity_id="spec-gsi4")
        limits = [Limit.per_minute("rpm", 100)]
        now_ms = int(time.time() * 1000)
        states = [BucketState.from_limit("spec-gsi4", "api", lim, now_ms) for lim in limits]
        create_item = repo.build_composite_create(
            entity_id="spec-gsi4", resource="api", states=states, now_ms=now_ms
        )
        repo.transact_write([create_item])
        repo.speculative_consume(entity_id="spec-gsi4", resource="api", consume={"rpm": 1000})
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_bucket("default", "spec-gsi4", "api", 0)},
                "SK": {"S": schema.sk_state()},
            },
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == "BUCKET#spec-gsi4#api#0"

    def test_adjust_does_not_set_gsi4(self, repo):
        """build_composite_adjust() does NOT set GSI4 (update path, not creation)."""
        from zae_limiter import schema
        from zae_limiter.models import Limit

        repo.create_entity(entity_id="adj-gsi4")
        limits = [Limit.per_minute("rpm", 100)]
        now_ms = int(time.time() * 1000)
        states = [BucketState.from_limit("adj-gsi4", "api", lim, now_ms) for lim in limits]
        create_item = repo.build_composite_create(
            entity_id="adj-gsi4", resource="api", states=states, now_ms=now_ms
        )
        repo.transact_write([create_item])
        adjust_item = repo.build_composite_adjust(
            entity_id="adj-gsi4", resource="api", deltas={"rpm": -500}
        )
        repo.write_each([adjust_item])
        client = repo._get_client()
        response = client.get_item(
            TableName=repo.table_name,
            Key={
                "PK": {"S": schema.pk_bucket("default", "adj-gsi4", "api", 0)},
                "SK": {"S": schema.sk_state()},
            },
        )
        item = response["Item"]
        assert item["GSI4PK"]["S"] == "default"
        assert item["GSI4SK"]["S"] == "BUCKET#adj-gsi4#api#0"


class TestDeleteStack:
    """Tests for SyncRepository.delete_stack()."""

    def test_delete_stack_delegates_to_stack_manager(self, mock_dynamodb):
        """delete_stack() creates a SyncStackManager and calls delete_stack."""
        from unittest.mock import MagicMock, patch

        repo = SyncRepository(name="test-stack", region="us-east-1", _skip_deprecation_warning=True)
        mock_manager = MagicMock()
        mock_manager.delete_stack = MagicMock()
        mock_manager.__enter__ = MagicMock(return_value=mock_manager)
        mock_manager.__exit__ = MagicMock(return_value=False)
        with patch(
            "zae_limiter.infra.sync_stack_manager.SyncStackManager", return_value=mock_manager
        ):
            repo.delete_stack()
        mock_manager.delete_stack.assert_called_once_with("test-stack")
        repo.close()


class TestResolveOnUnavailable:
    """Tests for SyncRepository.resolve_on_unavailable() edge cases."""

    def test_cached_value_returned_when_system_config_has_no_on_unavailable(self, mock_dynamodb):
        """When system config exists but on_unavailable is None, cached value is used."""
        from unittest.mock import MagicMock, patch

        repo = SyncRepository(name="test-cache", region="us-east-1", _skip_deprecation_warning=True)
        repo._on_unavailable_cache = "allow"
        with patch.object(
            repo._config_cache, "get_system_defaults", new_callable=MagicMock
        ) as mock_get:
            mock_get.return_value = (None, None)
            result = repo.resolve_on_unavailable()
        assert result == "allow"
        repo.close()

    def test_default_block_when_no_cache_and_no_system_config(self, mock_dynamodb):
        """When no cache and system config has no on_unavailable, default to 'block'."""
        from unittest.mock import MagicMock, patch

        repo = SyncRepository(
            name="test-default", region="us-east-1", _skip_deprecation_warning=True
        )
        assert repo._on_unavailable_cache is None
        with patch.object(
            repo._config_cache, "get_system_defaults", new_callable=MagicMock
        ) as mock_get:
            mock_get.return_value = (None, None)
            result = repo.resolve_on_unavailable()
        assert result == "block"
        repo.close()

    def test_cached_value_on_dynamodb_error(self, mock_dynamodb):
        """When DynamoDB is unreachable, cached on_unavailable is returned."""
        from unittest.mock import MagicMock, patch

        repo = SyncRepository(
            name="test-fallback", region="us-east-1", _skip_deprecation_warning=True
        )
        repo._on_unavailable_cache = "allow"
        with patch.object(
            repo._config_cache,
            "get_system_defaults",
            new_callable=MagicMock,
            side_effect=Exception("DynamoDB unavailable"),
        ):
            result = repo.resolve_on_unavailable()
        assert result == "allow"
        repo.close()

    def test_default_block_on_dynamodb_error_without_cache(self, mock_dynamodb):
        """When DynamoDB is unreachable and no cache, default to 'block'."""
        from unittest.mock import MagicMock, patch

        repo = SyncRepository(
            name="test-no-cache", region="us-east-1", _skip_deprecation_warning=True
        )
        assert repo._on_unavailable_cache is None
        with patch.object(
            repo._config_cache,
            "get_system_defaults",
            new_callable=MagicMock,
            side_effect=Exception("DynamoDB unavailable"),
        ):
            result = repo.resolve_on_unavailable()
        assert result == "block"
        repo.close()


class TestProvisionerState:
    """Tests for provisioner state CRUD (declarative limits management)."""

    def test_get_provisioner_state_empty(self, repo):
        """get_provisioner_state returns empty state when no record exists."""
        state = repo.get_provisioner_state()
        assert state["managed_system"] is False
        assert state["managed_resources"] == []
        assert state["managed_entities"] == {}
        assert state["last_applied"] is None
        assert state["applied_hash"] is None

    def test_put_get_provisioner_state_roundtrip(self, repo):
        """put_provisioner_state and get_provisioner_state round-trip correctly."""
        state = {
            "managed_system": True,
            "managed_resources": ["gpt-4", "claude-3"],
            "managed_entities": {"user-123": ["gpt-4"], "org-456": ["_default_"]},
            "last_applied": "2026-02-19T12:00:00Z",
            "applied_hash": "sha256:abc123",
        }
        repo.put_provisioner_state(state)
        retrieved = repo.get_provisioner_state()
        assert retrieved["managed_system"] is True
        assert sorted(retrieved["managed_resources"]) == ["claude-3", "gpt-4"]
        assert retrieved["managed_entities"] == {"user-123": ["gpt-4"], "org-456": ["_default_"]}
        assert retrieved["last_applied"] == "2026-02-19T12:00:00Z"
        assert retrieved["applied_hash"] == "sha256:abc123"

    def test_put_provisioner_state_overwrites(self, repo):
        """put_provisioner_state replaces previous state entirely."""
        state1 = {
            "managed_system": True,
            "managed_resources": ["gpt-4"],
            "managed_entities": {},
            "last_applied": "2026-02-19T12:00:00Z",
            "applied_hash": "sha256:aaa",
        }
        repo.put_provisioner_state(state1)
        state2 = {
            "managed_system": False,
            "managed_resources": ["claude-3"],
            "managed_entities": {"user-1": ["claude-3"]},
            "last_applied": "2026-02-19T13:00:00Z",
            "applied_hash": "sha256:bbb",
        }
        repo.put_provisioner_state(state2)
        retrieved = repo.get_provisioner_state()
        assert retrieved["managed_system"] is False
        assert retrieved["managed_resources"] == ["claude-3"]
        assert retrieved["managed_entities"] == {"user-1": ["claude-3"]}


class TestPreShardBuckets:
    """Tests for pre-shard bucket PK scheme."""

    def test_build_composite_create_new_pk(self, repo):
        """Bucket items use new PK scheme with wcu limit auto-injected."""
        from zae_limiter import schema

        now_ms = 1700000000000
        limits = [Limit.per_minute("rpm", 100)]
        states = [BucketState.from_limit("user-1", "gpt-4", lim, now_ms) for lim in limits]
        item = repo.build_composite_create(
            entity_id="user-1",
            resource="gpt-4",
            states=states,
            now_ms=now_ms,
            shard_id=0,
            shard_count=1,
        )
        put_item = item["Put"]["Item"]
        assert put_item["PK"]["S"] == schema.pk_bucket(repo._namespace_id, "user-1", "gpt-4", 0)
        assert put_item["SK"]["S"] == schema.sk_state()
        assert put_item["GSI3PK"]["S"] == schema.gsi3_pk_entity(repo._namespace_id, "user-1")
        assert put_item["GSI3SK"]["S"] == schema.gsi3_sk_bucket("gpt-4", 0)
        assert schema.bucket_attr("wcu", "tk") in put_item
        assert schema.bucket_attr("wcu", "cp") in put_item
        assert put_item["shard_count"]["N"] == "1"

    def test_build_composite_create_multi_shard(self, repo):
        """Bucket with shard_id > 0 uses correct PK."""
        from zae_limiter import schema

        now_ms = 1700000000000
        limits = [Limit.per_minute("rpm", 100)]
        states = [BucketState.from_limit("user-1", "gpt-4", lim, now_ms) for lim in limits]
        item = repo.build_composite_create(
            entity_id="user-1",
            resource="gpt-4",
            states=states,
            now_ms=now_ms,
            shard_id=3,
            shard_count=4,
        )
        put_item = item["Put"]["Item"]
        assert put_item["PK"]["S"] == schema.pk_bucket(repo._namespace_id, "user-1", "gpt-4", 3)
        assert put_item["shard_count"]["N"] == "4"
        assert put_item["GSI3SK"]["S"] == schema.gsi3_sk_bucket("gpt-4", 3)

    def test_speculative_consume_includes_wcu_consumption(self, repo):
        """Speculative consume adds wcu consumption (1 WCU = 1000 milli per write)."""
        from zae_limiter import schema

        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1000)]
        states = [BucketState.from_limit("e1", "gpt-4", lim, now_ms) for lim in limits]
        put_item = repo.build_composite_create("e1", "gpt-4", states, now_ms)
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 10})
        assert result.success is True
        wcu_bucket = next(b for b in result.buckets if b.limit_name == "wcu")
        assert wcu_bucket.tokens_milli == schema.WCU_LIMIT_CAPACITY * 1000 - 1000

    def test_speculative_consume_returns_shard_count(self, repo):
        """Speculative consume returns shard_count from SpeculativeResult."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1000)]
        states = [BucketState.from_limit("e1", "gpt-4", lim, now_ms) for lim in limits]
        put_item = repo.build_composite_create("e1", "gpt-4", states, now_ms)
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
        assert result.success is True
        assert result.shard_id == 0
        assert result.shard_count == 1

    def test_entity_cache_stores_shard_count(self, repo):
        """Entity cache includes shard_count per resource after speculative consume."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1000)]
        states = [BucketState.from_limit("e1", "gpt-4", lim, now_ms) for lim in limits]
        put_item = repo.build_composite_create("e1", "gpt-4", states, now_ms)
        repo.transact_write([put_item])
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
        assert result.success is True
        cache_entry = repo._entity_cache[repo._namespace_id, "e1"]
        assert len(cache_entry) == 3
        assert cache_entry[2]["gpt-4"] == 1

    def test_entity_cache_merges_shard_counts_across_resources(self, repo):
        """Entity cache merges shard_count from different resources."""
        now_ms = int(time.time() * 1000)
        for resource in ["gpt-4", "claude-3"]:
            limits = [Limit.per_minute("rpm", 1000)]
            states = [BucketState.from_limit("e1", resource, lim, now_ms) for lim in limits]
            put_item = repo.build_composite_create("e1", resource, states, now_ms)
            repo.transact_write([put_item])
        result1 = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
        assert result1.success is True
        result2 = repo.speculative_consume("e1", "claude-3", {"rpm": 1})
        assert result2.success is True
        cache_entry = repo._entity_cache[repo._namespace_id, "e1"]
        assert cache_entry[2]["gpt-4"] == 1
        assert cache_entry[2]["claude-3"] == 1

    def test_speculative_consume_fails_when_wcu_exhausted(self, repo):
        """Speculative consume fails when wcu tokens are exhausted."""
        from zae_limiter import schema

        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 100000)]
        states = [BucketState.from_limit("e1", "gpt-4", lim, now_ms) for lim in limits]
        put_item = repo.build_composite_create("e1", "gpt-4", states, now_ms)
        repo.transact_write([put_item])
        for _ in range(schema.WCU_LIMIT_CAPACITY):
            result = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
            assert result.success is True
        result = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
        assert result.success is False

    def test_speculative_consume_routes_to_random_shard(self, repo):
        """With cached shard_count > 1, speculative_consume routes to random shard."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 1000)]
        for shard_id in range(2):
            states = [BucketState.from_limit("e1", "gpt-4", lim, now_ms) for lim in limits]
            put_item = repo.build_composite_create(
                "e1", "gpt-4", states, now_ms, shard_id=shard_id, shard_count=2
            )
            repo.transact_write([put_item])
        repo._entity_cache[repo._namespace_id, "e1"] = (False, None, {"gpt-4": 2})
        shard_ids_hit = set()
        for _ in range(30):
            result = repo.speculative_consume("e1", "gpt-4", {"rpm": 1})
            assert result.success is True
            shard_ids_hit.add(result.shard_id)
        assert len(shard_ids_hit) == 2


class TestBumpShardCount:
    """Tests for bump_shard_count conditional write behavior."""

    def test_bump_shard_count_doubles_on_success(self, repo):
        """bump_shard_count doubles shard_count and updates entity cache."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 100000)]
        states = [BucketState.from_limit("e1", "gpt-4", lim, now_ms) for lim in limits]
        put_item = repo.build_composite_create(
            "e1", "gpt-4", states, now_ms, shard_id=0, shard_count=1
        )
        repo.transact_write([put_item])
        result = repo.bump_shard_count("e1", "gpt-4", current_count=1)
        assert result == 2
        cache_key = (repo._namespace_id, "e1")
        assert repo._entity_cache[cache_key][2]["gpt-4"] == 2

    def test_bump_shard_count_returns_current_on_race(self, repo):
        """bump_shard_count returns current_count when another client already doubled."""
        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 100000)]
        states = [BucketState.from_limit("e1", "gpt-4", lim, now_ms) for lim in limits]
        put_item = repo.build_composite_create(
            "e1", "gpt-4", states, now_ms, shard_id=0, shard_count=2
        )
        repo.transact_write([put_item])
        result = repo.bump_shard_count("e1", "gpt-4", current_count=1)
        assert result == 1

    def test_bump_shard_count_reraises_other_errors(self, repo):
        """bump_shard_count re-raises non-ConditionalCheckFailedException errors."""
        with patch.object(repo, "_get_client") as mock_get_client:
            mock_client = MagicMock()
            mock_client.update_item.side_effect = ClientError(
                {"Error": {"Code": "ProvisionedThroughputExceededException"}}, "UpdateItem"
            )
            mock_get_client.return_value = mock_client
            with pytest.raises(ClientError) as exc_info:
                repo.bump_shard_count("e1", "gpt-4", current_count=1)
            assert (
                exc_info.value.response["Error"]["Code"] == "ProvisionedThroughputExceededException"
            )

    def test_bump_shard_count_warns_above_threshold(self, repo, caplog):
        """bump_shard_count logs warning when new count exceeds threshold."""
        import logging

        now_ms = int(time.time() * 1000)
        limits = [Limit.per_minute("rpm", 100000)]
        states = [BucketState.from_limit("e1", "gpt-4", lim, now_ms) for lim in limits]
        put_item = repo.build_composite_create(
            "e1", "gpt-4", states, now_ms, shard_id=0, shard_count=32
        )
        repo.transact_write([put_item])
        with caplog.at_level(logging.WARNING, logger="zae_limiter.sync_repository"):
            result = repo.bump_shard_count("e1", "gpt-4", current_count=32)
        assert result == 64
        assert any(
            "shard count" in r.message.lower() and "64" in str(r.message)
            for r in caplog.records
            if r.levelno >= logging.WARNING
        )
