from __future__ import annotations

from datetime import datetime, timedelta, timezone

import pytest

from app.core.utils.time import utcnow
from app.db.models import Account, AccountStatus, ApiKey, ApiKeyAccountAssignment, ApiKeyLimit, LimitType
from app.modules.api_keys.repository import (
    _UNSET,
    ApiKeyTrendBucket,
    ApiKeyUsageSummary,
    ReservationResult,
    UsageReservationData,
    UsageReservationItemData,
    _Unset,
)
from app.modules.api_keys.service import (
    ApiKeyCreateData,
    ApiKeyInvalidError,
    ApiKeyRateLimitExceededError,
    ApiKeysRepositoryProtocol,
    ApiKeysService,
    ApiKeyUpdateData,
    LimitRuleInput,
    _build_api_key_trends,
)

pytestmark = pytest.mark.unit


class _FakeApiKeysRepository(ApiKeysRepositoryProtocol):
    def __init__(self) -> None:
        self.rows: dict[str, ApiKey] = {}
        self._limits: dict[str, list[ApiKeyLimit]] = {}
        self._account_assignments: dict[str, list[ApiKeyAccountAssignment]] = {}
        self._accounts: dict[str, Account] = {}
        self._limit_id_seq = 0
        self._reservations: dict[str, UsageReservationData] = {}

    async def create(self, row: ApiKey) -> ApiKey:
        self.rows[row.id] = row
        row.limits = []
        row.account_assignments = []
        return row

    async def get_by_id(self, key_id: str) -> ApiKey | None:
        row = self.rows.get(key_id)
        if row is not None:
            row.limits = self._limits.get(key_id, [])
            row.account_assignments = self._account_assignments.get(key_id, [])
        return row

    async def get_by_hash(self, key_hash: str) -> ApiKey | None:
        for row in self.rows.values():
            if row.key_hash == key_hash:
                row.limits = self._limits.get(row.id, [])
                row.account_assignments = self._account_assignments.get(row.id, [])
                return row
        return None

    async def list_all(self) -> list[ApiKey]:
        result = sorted(self.rows.values(), key=lambda row: row.created_at, reverse=True)
        for row in result:
            row.limits = self._limits.get(row.id, [])
            row.account_assignments = self._account_assignments.get(row.id, [])
        return result

    async def list_accounts_by_ids(self, account_ids: list[str]) -> list[Account]:
        return [self._accounts[account_id] for account_id in account_ids if account_id in self._accounts]

    async def list_usage_summary_by_key(self) -> dict[str, ApiKeyUsageSummary]:
        return {}

    async def update(
        self,
        key_id: str,
        *,
        name: str | _Unset = _UNSET,
        allowed_models: str | None | _Unset = _UNSET,
        enforced_model: str | None | _Unset = _UNSET,
        enforced_reasoning_effort: str | None | _Unset = _UNSET,
        enforced_service_tier: str | None | _Unset = _UNSET,
        account_assignment_scope_enabled: bool | _Unset = _UNSET,
        expires_at: datetime | None | _Unset = _UNSET,
        is_active: bool | _Unset = _UNSET,
        key_hash: str | _Unset = _UNSET,
        key_prefix: str | _Unset = _UNSET,
        commit: bool = True,
    ) -> ApiKey | None:
        del commit
        row = self.rows.get(key_id)
        if row is None:
            return None
        for field, value in {
            "name": name,
            "allowed_models": allowed_models,
            "enforced_model": enforced_model,
            "enforced_reasoning_effort": enforced_reasoning_effort,
            "enforced_service_tier": enforced_service_tier,
            "account_assignment_scope_enabled": account_assignment_scope_enabled,
            "expires_at": expires_at,
            "is_active": is_active,
            "key_hash": key_hash,
            "key_prefix": key_prefix,
        }.items():
            if value is _UNSET:
                continue
            setattr(row, field, value)
        row.limits = self._limits.get(key_id, [])
        return row

    async def delete(self, key_id: str) -> bool:
        if key_id not in self.rows:
            return False
        self.rows.pop(key_id)
        self._limits.pop(key_id, None)
        return True

    async def update_last_used(self, key_id: str) -> None:
        row = self.rows.get(key_id)
        if row is not None:
            row.last_used_at = utcnow()

    async def commit(self) -> None:
        return None

    async def rollback(self) -> None:
        return None

    async def get_limits_by_key(self, key_id: str) -> list[ApiKeyLimit]:
        return list(self._limits.get(key_id, []))

    async def replace_limits(self, key_id: str, limits: list[ApiKeyLimit]) -> list[ApiKeyLimit]:
        for limit in limits:
            self._limit_id_seq += 1
            limit.id = self._limit_id_seq
            limit.api_key_id = key_id
        self._limits[key_id] = list(limits)
        row = self.rows.get(key_id)
        if row is not None:
            row.limits = self._limits[key_id]
        return self._limits[key_id]

    async def upsert_limits(self, key_id: str, limits: list[ApiKeyLimit], *, commit: bool = True) -> list[ApiKeyLimit]:
        del commit
        existing = self._limits.get(key_id, [])
        existing_by_key = {(limit.limit_type, limit.limit_window, limit.model_filter): limit for limit in existing}

        updated: list[ApiKeyLimit] = []
        for incoming in limits:
            key = (incoming.limit_type, incoming.limit_window, incoming.model_filter)
            matched = existing_by_key.get(key)
            if matched is not None:
                matched.max_value = incoming.max_value
                matched.current_value = incoming.current_value
                matched.reset_at = incoming.reset_at
                updated.append(matched)
                continue
            self._limit_id_seq += 1
            incoming.id = self._limit_id_seq
            incoming.api_key_id = key_id
            updated.append(incoming)

        self._limits[key_id] = updated
        row = self.rows.get(key_id)
        if row is not None:
            row.limits = updated
        return updated

    async def replace_account_assignments(self, key_id: str, account_ids: list[str], *, commit: bool = True) -> None:
        del commit
        assignments = [ApiKeyAccountAssignment(api_key_id=key_id, account_id=account_id) for account_id in account_ids]
        self._account_assignments[key_id] = assignments
        row = self.rows.get(key_id)
        if row is not None:
            row.account_assignments = assignments

    async def increment_limit_usage(
        self,
        key_id: str,
        *,
        model: str,
        input_tokens: int,
        output_tokens: int,
        cost_microdollars: int,
    ) -> None:
        limits = self._limits.get(key_id, [])
        for limit in limits:
            if limit.model_filter is not None and limit.model_filter != model:
                continue
            increment = _compute_increment(limit, input_tokens, output_tokens, cost_microdollars)
            if increment > 0:
                limit.current_value += increment
        row = self.rows.get(key_id)
        if row is not None:
            row.last_used_at = utcnow()

    async def reset_limit(self, limit_id: int, *, expected_reset_at: datetime, new_reset_at: datetime) -> bool:
        for limits in self._limits.values():
            for limit in limits:
                if limit.id == limit_id and limit.reset_at == expected_reset_at:
                    limit.current_value = 0
                    limit.reset_at = new_reset_at
                    return True
        return False

    async def try_reserve_usage(
        self,
        limit_id: int,
        *,
        delta: int,
        expected_reset_at: datetime,
    ) -> ReservationResult:
        limit = _find_limit_by_id(self._limits, limit_id)
        if limit is None:
            return ReservationResult(False, limit_id, None, None, None)
        if limit.reset_at != expected_reset_at:
            return ReservationResult(False, limit_id, limit.current_value, limit.max_value, limit.reset_at)
        if limit.current_value + delta > limit.max_value:
            return ReservationResult(False, limit_id, limit.current_value, limit.max_value, limit.reset_at)
        limit.current_value += delta
        return ReservationResult(True, limit_id, limit.current_value, limit.max_value, limit.reset_at)

    async def adjust_reserved_usage(
        self,
        limit_id: int,
        *,
        delta: int,
        expected_reset_at: datetime,
    ) -> bool:
        limit = _find_limit_by_id(self._limits, limit_id)
        if limit is None or limit.reset_at != expected_reset_at:
            return False
        next_value = limit.current_value + delta
        if next_value < 0:
            return False
        limit.current_value = next_value
        return True

    async def create_usage_reservation(
        self,
        reservation_id: str,
        *,
        key_id: str,
        model: str,
        items: list[UsageReservationItemData],
    ) -> None:
        self._reservations[reservation_id] = UsageReservationData(
            reservation_id=reservation_id,
            api_key_id=key_id,
            model=model,
            status="reserved",
            items=[
                UsageReservationItemData(
                    limit_id=item.limit_id,
                    limit_type=item.limit_type,
                    reserved_delta=item.reserved_delta,
                    expected_reset_at=item.expected_reset_at,
                    actual_delta=item.actual_delta,
                )
                for item in items
            ],
        )

    async def get_usage_reservation(self, reservation_id: str) -> UsageReservationData | None:
        reservation = self._reservations.get(reservation_id)
        if reservation is None:
            return None
        return UsageReservationData(
            reservation_id=reservation.reservation_id,
            api_key_id=reservation.api_key_id,
            model=reservation.model,
            status=reservation.status,
            items=[
                UsageReservationItemData(
                    limit_id=item.limit_id,
                    limit_type=item.limit_type,
                    reserved_delta=item.reserved_delta,
                    expected_reset_at=item.expected_reset_at,
                    actual_delta=item.actual_delta,
                )
                for item in reservation.items
            ],
        )

    async def transition_usage_reservation_status(
        self,
        reservation_id: str,
        *,
        expected_status: str,
        new_status: str,
    ) -> bool:
        reservation = self._reservations.get(reservation_id)
        if reservation is None or reservation.status != expected_status:
            return False
        self._reservations[reservation_id] = UsageReservationData(
            reservation_id=reservation.reservation_id,
            api_key_id=reservation.api_key_id,
            model=reservation.model,
            status=new_status,
            items=reservation.items,
        )
        return True

    async def upsert_reservation_item_actual(
        self,
        reservation_id: str,
        *,
        item: UsageReservationItemData,
        actual_delta: int,
    ) -> None:
        reservation = self._reservations.get(reservation_id)
        if reservation is None:
            return
        updated_items: list[UsageReservationItemData] = []
        found = False
        for existing in reservation.items:
            if existing.limit_id == item.limit_id:
                updated_items.append(
                    UsageReservationItemData(
                        limit_id=existing.limit_id,
                        limit_type=existing.limit_type,
                        reserved_delta=existing.reserved_delta,
                        expected_reset_at=existing.expected_reset_at,
                        actual_delta=actual_delta,
                    )
                )
                found = True
            else:
                updated_items.append(existing)
        if not found:
            updated_items.append(
                UsageReservationItemData(
                    limit_id=item.limit_id,
                    limit_type=item.limit_type,
                    reserved_delta=item.reserved_delta,
                    expected_reset_at=item.expected_reset_at,
                    actual_delta=actual_delta,
                )
            )
        self._reservations[reservation_id] = UsageReservationData(
            reservation_id=reservation.reservation_id,
            api_key_id=reservation.api_key_id,
            model=reservation.model,
            status=reservation.status,
            items=updated_items,
        )

    async def settle_usage_reservation(
        self,
        reservation_id: str,
        *,
        status: str,
        input_tokens: int | None,
        output_tokens: int | None,
        cached_input_tokens: int | None,
        cost_microdollars: int | None,
    ) -> None:
        reservation = self._reservations.get(reservation_id)
        if reservation is None:
            return
        self._reservations[reservation_id] = UsageReservationData(
            reservation_id=reservation.reservation_id,
            api_key_id=reservation.api_key_id,
            model=reservation.model,
            status=status,
            items=reservation.items,
        )


def _compute_increment(limit: ApiKeyLimit, input_tokens: int, output_tokens: int, cost_microdollars: int) -> int:
    if limit.limit_type == LimitType.TOTAL_TOKENS:
        return input_tokens + output_tokens
    if limit.limit_type == LimitType.INPUT_TOKENS:
        return input_tokens
    if limit.limit_type == LimitType.OUTPUT_TOKENS:
        return output_tokens
    if limit.limit_type == LimitType.COST_USD:
        return cost_microdollars
    return 0


def _find_limit_by_id(
    limits_by_key: dict[str, list[ApiKeyLimit]],
    limit_id: int,
) -> ApiKeyLimit | None:
    for limits in limits_by_key.values():
        for limit in limits:
            if limit.id == limit_id:
                return limit
    return None


@pytest.mark.asyncio
async def test_create_key_stores_hash_and_prefix() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)

    created = await service.create_key(
        ApiKeyCreateData(
            name="dev-key",
            allowed_models=["o3-pro"],
            expires_at=None,
        )
    )

    assert created.key.startswith("sk-clb-")
    assert created.key_prefix == created.key[:15]
    assert created.allowed_models == ["o3-pro"]

    stored = await repo.get_by_id(created.id)
    assert stored is not None
    assert stored.key_hash != created.key
    assert stored.key_prefix == created.key[:15]


@pytest.mark.asyncio
async def test_create_key_normalizes_timezone_aware_expiry_to_utc_naive() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)

    created = await service.create_key(
        ApiKeyCreateData(
            name="expiring-key",
            allowed_models=None,
            expires_at=datetime(2026, 3, 20, 23, 59, 59, tzinfo=timezone(timedelta(hours=9))),
        )
    )

    assert created.expires_at == datetime(2026, 3, 20, 14, 59, 59)

    stored = await repo.get_by_id(created.id)
    assert stored is not None
    assert stored.expires_at == datetime(2026, 3, 20, 14, 59, 59)


@pytest.mark.asyncio
async def test_create_key_rejects_enforced_model_outside_allowed_models() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)

    with pytest.raises(ValueError, match="enforced_model"):
        await service.create_key(
            ApiKeyCreateData(
                name="invalid-policy",
                allowed_models=["model-alpha"],
                enforced_model="model-beta",
                expires_at=None,
            )
        )


@pytest.mark.asyncio
async def test_create_key_normalizes_enforced_reasoning_effort() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)

    created = await service.create_key(
        ApiKeyCreateData(
            name="reasoning-policy",
            allowed_models=None,
            enforced_reasoning_effort="HIGH",
            expires_at=None,
        )
    )

    assert created.enforced_reasoning_effort == "high"


@pytest.mark.asyncio
async def test_create_key_normalizes_fast_service_tier_alias() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)

    created = await service.create_key(
        ApiKeyCreateData(
            name="service-tier-policy",
            allowed_models=None,
            enforced_service_tier="FAST",
            expires_at=None,
        )
    )

    assert created.enforced_service_tier == "priority"


@pytest.mark.asyncio
async def test_update_key_normalizes_service_tier_alias() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)

    created = await service.create_key(
        ApiKeyCreateData(
            name="service-tier-update",
            allowed_models=None,
            expires_at=None,
        )
    )

    updated = await service.update_key(
        created.id,
        ApiKeyUpdateData(
            enforced_service_tier="fast",
            enforced_service_tier_set=True,
        ),
    )

    assert updated.enforced_service_tier == "priority"


@pytest.mark.asyncio
async def test_update_key_tracks_assignment_scope_after_clear() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    repo._accounts = {
        "acc-a": Account(
            id="acc-a",
            chatgpt_account_id=None,
            email="a@example.com",
            plan_type="plus",
            access_token_encrypted=b"access",
            refresh_token_encrypted=b"refresh",
            id_token_encrypted=b"id",
            last_refresh=utcnow(),
            status=AccountStatus.ACTIVE,
        ),
    }

    created = await service.create_key(
        ApiKeyCreateData(
            name="assignment-scope",
            allowed_models=None,
            expires_at=None,
        )
    )

    scoped = await service.update_key(
        created.id,
        ApiKeyUpdateData(
            assigned_account_ids=["acc-a"],
            assigned_account_ids_set=True,
        ),
    )
    assert scoped.account_assignment_scope_enabled is True
    assert scoped.assigned_account_ids == ["acc-a"]

    cleared = await service.update_key(
        created.id,
        ApiKeyUpdateData(
            assigned_account_ids=[],
            assigned_account_ids_set=True,
        ),
    )
    assert cleared.account_assignment_scope_enabled is False
    assert cleared.assigned_account_ids == []


@pytest.mark.asyncio
async def test_create_key_with_limits() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)

    created = await service.create_key(
        ApiKeyCreateData(
            name="limited-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=1_000_000),
                LimitRuleInput(limit_type="cost_usd", limit_window="daily", max_value=5_000_000),
            ],
        )
    )

    assert len(created.limits) == 2
    token_limit = next(lim for lim in created.limits if lim.limit_type == "total_tokens")
    cost_limit = next(lim for lim in created.limits if lim.limit_type == "cost_usd")
    assert token_limit.max_value == 1_000_000
    assert token_limit.limit_window == "weekly"
    assert token_limit.current_value == 0
    assert cost_limit.max_value == 5_000_000
    assert cost_limit.limit_window == "daily"


@pytest.mark.asyncio
async def test_validate_key_checks_expiry_and_limit() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="limited-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=10),
            ],
        )
    )

    # validate_key now checks auth/expiry only.
    limits = await repo.get_limits_by_key(created.id)
    limits[0].current_value = 10
    limits[0].reset_at = utcnow() + timedelta(days=1)
    validated = await service.validate_key(created.key)
    assert validated.id == created.id

    with pytest.raises(ApiKeyRateLimitExceededError):
        await service.enforce_limits_for_request(created.id, request_model="gpt-5")

    # Test expiry
    limits[0].current_value = 5
    row = await repo.get_by_id(created.id)
    assert row is not None
    row.expires_at = utcnow() - timedelta(seconds=1)
    with pytest.raises(ApiKeyInvalidError):
        await service.validate_key(created.key)


@pytest.mark.asyncio
async def test_validate_key_lazy_resets_expired_limit() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="reset-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=10),
            ],
        )
    )

    # Set limit as expired
    limits = await repo.get_limits_by_key(created.id)
    limits[0].current_value = 9
    limits[0].reset_at = utcnow() - timedelta(days=8)

    validated = await service.validate_key(created.key)
    assert validated.id == created.id

    # Verify lazy reset occurred
    updated_limits = await repo.get_limits_by_key(created.id)
    assert updated_limits[0].current_value == 0
    assert updated_limits[0].reset_at > utcnow()


@pytest.mark.asyncio
async def test_validate_key_does_not_refetch_when_limits_do_not_need_reset() -> None:
    class _CountingRepo(_FakeApiKeysRepository):
        def __init__(self) -> None:
            super().__init__()
            self.get_by_hash_calls = 0

        async def get_by_hash(self, key_hash: str) -> ApiKey | None:
            self.get_by_hash_calls += 1
            return await super().get_by_hash(key_hash)

    repo = _CountingRepo()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="single-fetch",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=10),
            ],
        )
    )

    validated = await service.validate_key(created.key)

    assert validated.id == created.id
    assert repo.get_by_hash_calls == 1


@pytest.mark.asyncio
async def test_validate_key_advances_reset_strictly_into_future(monkeypatch: pytest.MonkeyPatch) -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="boundary-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=10),
            ],
        )
    )
    fixed_now = utcnow()
    monkeypatch.setattr("app.modules.api_keys.service.utcnow", lambda: fixed_now)

    limits = await repo.get_limits_by_key(created.id)
    limits[0].current_value = 7
    limits[0].reset_at = fixed_now - timedelta(days=14)

    await service.validate_key(created.key)

    updated_limits = await repo.get_limits_by_key(created.id)
    assert updated_limits[0].current_value == 0
    assert updated_limits[0].reset_at == fixed_now + timedelta(days=7)


@pytest.mark.asyncio
async def test_validate_key_multi_limit_all_must_pass() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="multi-limit-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=100),
                LimitRuleInput(limit_type="cost_usd", limit_window="daily", max_value=5_000_000),
            ],
        )
    )

    limits = await repo.get_limits_by_key(created.id)
    token_limit = next(lim for lim in limits if lim.limit_type == LimitType.TOTAL_TOKENS)
    cost_limit = next(lim for lim in limits if lim.limit_type == LimitType.COST_USD)

    # Token within range, cost exceeded → should fail
    token_limit.current_value = 50
    cost_limit.current_value = 5_000_000
    token_limit.reset_at = utcnow() + timedelta(days=1)
    cost_limit.reset_at = utcnow() + timedelta(days=1)

    with pytest.raises(ApiKeyRateLimitExceededError) as exc_info:
        await service.enforce_limits_for_request(created.id, request_model="gpt-5.1")
    assert "cost_usd" in str(exc_info.value)


@pytest.mark.asyncio
async def test_enforce_limits_reserves_tier_aware_cost_budget() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    priority_created = await service.create_key(
        ApiKeyCreateData(
            name="priority-cost-reserve-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="cost_usd", limit_window="weekly", max_value=1_000_000),
            ],
        )
    )

    priority_reservation = await service.enforce_limits_for_request(
        priority_created.id,
        request_model="gpt-5.4",
        request_service_tier="priority",
    )
    assert priority_reservation.key_id == priority_created.id

    priority_limits = await repo.get_limits_by_key(priority_created.id)
    priority_cost_limit = next(lim for lim in priority_limits if lim.limit_type == LimitType.COST_USD)
    assert priority_cost_limit.current_value == 286_720

    standard_created = await service.create_key(
        ApiKeyCreateData(
            name="standard-cost-reserve-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="cost_usd", limit_window="weekly", max_value=1_000_000),
            ],
        )
    )
    standard_reservation = await service.enforce_limits_for_request(
        standard_created.id,
        request_model="gpt-5.4",
        request_service_tier=None,
    )
    assert standard_reservation.key_id == standard_created.id

    standard_limits = await repo.get_limits_by_key(standard_created.id)
    standard_cost_limit = next(lim for lim in standard_limits if lim.limit_type == LimitType.COST_USD)
    assert standard_cost_limit.current_value == 143_360


@pytest.mark.asyncio
async def test_update_key_normalizes_timezone_aware_expiry_to_utc_naive() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(ApiKeyCreateData(name="update-expiry", allowed_models=None, expires_at=None))

    updated = await service.update_key(
        created.id,
        ApiKeyUpdateData(
            expires_at=datetime(2026, 4, 1, 5, 30, 0, tzinfo=timezone(timedelta(hours=-7))),
            expires_at_set=True,
        ),
    )

    assert updated.expires_at == datetime(2026, 4, 1, 12, 30, 0)

    stored = await repo.get_by_id(created.id)
    assert stored is not None
    assert stored.expires_at == datetime(2026, 4, 1, 12, 30, 0)


@pytest.mark.asyncio
async def test_regenerate_key_rotates_hash_and_prefix() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(ApiKeyCreateData(name="regen-key", allowed_models=None, expires_at=None))

    row_before = await repo.get_by_id(created.id)
    assert row_before is not None
    old_hash = row_before.key_hash
    old_prefix = row_before.key_prefix

    regenerated = await service.regenerate_key(created.id)
    row_after = await repo.get_by_id(created.id)
    assert row_after is not None

    assert regenerated.key.startswith("sk-clb-")
    assert row_after.key_hash != old_hash
    assert row_after.key_prefix != old_prefix


@pytest.mark.asyncio
async def test_record_usage_increments_matching_limits() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="usage-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=1_000_000),
                LimitRuleInput(limit_type="input_tokens", limit_window="weekly", max_value=500_000),
                LimitRuleInput(limit_type="output_tokens", limit_window="weekly", max_value=500_000),
            ],
        )
    )

    await service.record_usage(
        created.id,
        model="test-model",
        input_tokens=100,
        output_tokens=50,
        cached_input_tokens=20,
    )

    limits = await repo.get_limits_by_key(created.id)
    total_limit = next(lim for lim in limits if lim.limit_type == LimitType.TOTAL_TOKENS)
    input_limit = next(lim for lim in limits if lim.limit_type == LimitType.INPUT_TOKENS)
    output_limit = next(lim for lim in limits if lim.limit_type == LimitType.OUTPUT_TOKENS)

    assert total_limit.current_value == 150  # input + output
    assert input_limit.current_value == 100
    assert output_limit.current_value == 50


@pytest.mark.asyncio
async def test_record_usage_model_filter_matching() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="model-filter-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=1_000_000),
                LimitRuleInput(
                    limit_type="total_tokens", limit_window="weekly", max_value=500_000, model_filter="gpt-5.1"
                ),
            ],
        )
    )

    # Record usage for gpt-5.1 → both limits should increment
    await service.record_usage(
        created.id,
        model="gpt-5.1",
        input_tokens=100,
        output_tokens=50,
    )

    limits = await repo.get_limits_by_key(created.id)
    global_limit = next(lim for lim in limits if lim.model_filter is None)
    model_limit = next(lim for lim in limits if lim.model_filter == "gpt-5.1")
    assert global_limit.current_value == 150
    assert model_limit.current_value == 150

    # Record usage for different model → only global limit increments
    await service.record_usage(
        created.id,
        model="gpt-4o-mini",
        input_tokens=200,
        output_tokens=100,
    )

    limits = await repo.get_limits_by_key(created.id)
    global_limit = next(lim for lim in limits if lim.model_filter is None)
    model_limit = next(lim for lim in limits if lim.model_filter == "gpt-5.1")
    assert global_limit.current_value == 450  # 150 + 300
    assert model_limit.current_value == 150  # unchanged


@pytest.mark.asyncio
async def test_record_usage_cost_limit_uses_service_tier_pricing() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="priority-cost-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="cost_usd", limit_window="weekly", max_value=100_000_000),
            ],
        )
    )

    await service.record_usage(
        created.id,
        model="gpt-5.4",
        input_tokens=1_000_000,
        output_tokens=1_000_000,
        service_tier="priority",
    )

    limits = await repo.get_limits_by_key(created.id)
    cost_limit = next(lim for lim in limits if lim.limit_type == LimitType.COST_USD)
    assert cost_limit.current_value == 35_000_000


@pytest.mark.asyncio
async def test_record_usage_cost_limit_uses_legacy_gpt_5_priority_pricing() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="legacy-priority-cost-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="cost_usd", limit_window="weekly", max_value=100_000_000),
            ],
        )
    )

    await service.record_usage(
        created.id,
        model="gpt-5.1",
        input_tokens=1_000_000,
        output_tokens=1_000_000,
        service_tier="priority",
    )

    limits = await repo.get_limits_by_key(created.id)
    cost_limit = next(lim for lim in limits if lim.limit_type == LimitType.COST_USD)
    assert cost_limit.current_value == 22_500_000


@pytest.mark.asyncio
async def test_record_usage_cost_limit_uses_flex_service_tier_pricing() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="flex-cost-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="cost_usd", limit_window="weekly", max_value=100_000_000),
            ],
        )
    )

    await service.record_usage(
        created.id,
        model="gpt-5.4-mini",
        input_tokens=1_000_000,
        output_tokens=1_000_000,
        service_tier="flex",
    )

    limits = await repo.get_limits_by_key(created.id)
    cost_limit = next(lim for lim in limits if lim.limit_type == LimitType.COST_USD)
    assert cost_limit.current_value == 2_625_000


@pytest.mark.asyncio
async def test_release_usage_reservation_restores_reserved_counter() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="reservation-release-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=100),
            ],
        )
    )

    reservation = await service.enforce_limits_for_request(created.id, request_model="gpt-5.1")
    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 100

    await service.release_usage_reservation(reservation.reservation_id)
    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 0


@pytest.mark.asyncio
async def test_finalize_usage_reservation_is_idempotent() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="reservation-finalize-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=100),
            ],
        )
    )

    reservation = await service.enforce_limits_for_request(created.id, request_model="gpt-5.1")
    await service.finalize_usage_reservation(
        reservation.reservation_id,
        model="gpt-5.1",
        input_tokens=10,
        output_tokens=5,
        cached_input_tokens=0,
    )
    await service.finalize_usage_reservation(
        reservation.reservation_id,
        model="gpt-5.1",
        input_tokens=10,
        output_tokens=5,
        cached_input_tokens=0,
    )

    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 15


@pytest.mark.asyncio
async def test_fail_usage_reservation_preserves_failed_request_record() -> None:
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="reservation-fail-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=100),
            ],
        )
    )

    reservation = await service.enforce_limits_for_request(created.id, request_model="gpt-5.1")
    await service.fail_usage_reservation(
        reservation.reservation_id,
        model="gpt-5.1",
        input_tokens=None,
        output_tokens=None,
        cached_input_tokens=None,
    )

    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 0
    stored = await repo.get_usage_reservation(reservation.reservation_id)
    assert stored is not None
    assert stored.status == "failed"


@pytest.mark.asyncio
async def test_release_after_finalize_is_noop() -> None:
    """Finalize 후 release 호출 시 quota 이중 반영 없음 (멱등성)."""
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="finalize-then-release-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=100),
            ],
        )
    )

    reservation = await service.enforce_limits_for_request(created.id, request_model="gpt-5.1")
    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 100  # reserved

    await service.finalize_usage_reservation(
        reservation.reservation_id,
        model="gpt-5.1",
        input_tokens=10,
        output_tokens=5,
        cached_input_tokens=0,
    )

    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 15  # finalized: 100 -> 15

    # Release after finalize should be no-op
    await service.release_usage_reservation(reservation.reservation_id)

    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 15  # unchanged


@pytest.mark.asyncio
async def test_finalize_after_release_is_noop() -> None:
    """Release 후 finalize 호출 시 quota 반영 없음 (멱등성)."""
    repo = _FakeApiKeysRepository()
    service = ApiKeysService(repo)
    created = await service.create_key(
        ApiKeyCreateData(
            name="release-then-finalize-key",
            allowed_models=None,
            expires_at=None,
            limits=[
                LimitRuleInput(limit_type="total_tokens", limit_window="weekly", max_value=100),
            ],
        )
    )

    reservation = await service.enforce_limits_for_request(created.id, request_model="gpt-5.1")

    await service.release_usage_reservation(reservation.reservation_id)

    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 0  # released: 100 -> 0

    # Finalize after release should be no-op
    await service.finalize_usage_reservation(
        reservation.reservation_id,
        model="gpt-5.1",
        input_tokens=10,
        output_tokens=5,
        cached_input_tokens=0,
    )

    limits = await repo.get_limits_by_key(created.id)
    assert limits[0].current_value == 0  # unchanged


def test_build_api_key_trends_includes_partial_boundary_hours() -> None:
    since = datetime(2026, 3, 23, 10, 37, 0)
    until = datetime(2026, 3, 30, 10, 37, 0)
    oldest_bucket = datetime(2026, 3, 23, 10, 0, 0, tzinfo=timezone.utc)
    newest_bucket = datetime(2026, 3, 30, 10, 0, 0, tzinfo=timezone.utc)

    trends = _build_api_key_trends(
        "key-123",
        [
            ApiKeyTrendBucket(bucket_epoch=int(oldest_bucket.timestamp()), total_tokens=5, total_cost_usd=0.1),
            ApiKeyTrendBucket(bucket_epoch=int(newest_bucket.timestamp()), total_tokens=7, total_cost_usd=0.2),
        ],
        since,
        until,
        bucket_seconds=3600,
    )

    assert len(trends.cost) == 169
    assert len(trends.tokens) == 169
    assert trends.cost[0].t == oldest_bucket
    assert trends.cost[-1].t == newest_bucket
    assert sum(point.v for point in trends.tokens) == pytest.approx(12.0)
    assert sum(point.v for point in trends.cost) == pytest.approx(0.3)


def test_build_api_key_trends_keeps_aligned_windows_at_168_buckets() -> None:
    since = datetime(2026, 3, 23, 11, 0, 0)
    until = datetime(2026, 3, 30, 11, 0, 0)
    oldest_bucket = datetime(2026, 3, 23, 11, 0, 0, tzinfo=timezone.utc)
    newest_bucket = datetime(2026, 3, 30, 10, 0, 0, tzinfo=timezone.utc)

    trends = _build_api_key_trends(
        "key-123",
        [
            ApiKeyTrendBucket(bucket_epoch=int(oldest_bucket.timestamp()), total_tokens=5, total_cost_usd=0.1),
            ApiKeyTrendBucket(bucket_epoch=int(newest_bucket.timestamp()), total_tokens=7, total_cost_usd=0.2),
        ],
        since,
        until,
        bucket_seconds=3600,
    )

    assert len(trends.cost) == 168
    assert len(trends.tokens) == 168
    assert trends.cost[0].t == oldest_bucket
    assert trends.cost[-1].t == newest_bucket
    assert sum(point.v for point in trends.tokens) == pytest.approx(12.0)
    assert sum(point.v for point in trends.cost) == pytest.approx(0.3)
