"""Idempotency-key store — the retry-safe POST primitive (C-3, v0.37.0).

Stability tier: **experimental** (AP-11) — added in 0.37.0.
Wave 3 federation primitive: the "double-charged retry" footgun
prevention. Pairs naturally with middleware in
:mod:`mgf.fastapi.idempotency` (ships in mgf-fastapi v0.7+) and
:mod:`mgf.django.idempotency` (v0.5+).

What ships here
---------------

- :class:`IdempotencyStore` — Protocol. Backends store
  ``(key, response_snapshot)`` for a TTL window; lookup
  during a retry returns the cached snapshot instead of
  re-running the operation.
- :class:`InMemoryIdempotencyStore` — default implementation.
  TTL-bounded; thread-safe; suitable for single-replica
  deployments. Production multi-replica deployments wire a
  Redis-backed impl with the same Protocol shape.
- :class:`IdempotencyRecord` — what's stored. Includes the
  response body + status code + headers snapshot + a
  ``created_at`` timestamp.
- :class:`IdempotencyKeyConflictError` — raised when the SAME key
  is presented with a DIFFERENT request body (the canonical
  Stripe-style "the key collided; retry would silently
  succeed with the wrong response").

Canonical use case
------------------

A payment endpoint::

    @app.post("/payments")
    async def create_payment(
        request: Request,
        body: PaymentRequest,
        idempotency: IdempotencyStore = Depends(get_idempotency_store),
    ):
        key = request.headers.get("Idempotency-Key")
        if not key:
            raise HTTPException(400, "missing Idempotency-Key header")

        body_hash = hashlib.sha256(body.model_dump_json().encode()).hexdigest()
        cached = idempotency.get(key, expected_body_hash=body_hash)
        if cached is not None:
            return cached.response_dict()  # replay

        result = process_payment(body)  # actually charge

        idempotency.put(
            key,
            body_hash=body_hash,
            status_code=200,
            body={"payment_id": result.id, "status": "ok"},
            headers={},
        )
        return result

Network blip + client retry → same key + same body → cached
replay (no double-charge). Different body with same key →
:class:`IdempotencyKeyConflictError` raised (caller surfaces as
422).

Cross-references
----------------

- **AP-11** — stability tiers.
- **C-3** federation row.
- Stripe's API design: <https://stripe.com/docs/api/idempotent_requests>
- :mod:`mgf.fastapi` future `IdempotencyMiddleware` (v0.7+).
"""

from __future__ import annotations

import threading
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from mgf.common.exceptions import OperationError

if TYPE_CHECKING:
    from collections.abc import Callable

__all__ = [
    "IdempotencyKeyConflictError",
    "IdempotencyRecord",
    "IdempotencyStore",
    "InMemoryIdempotencyStore",
]


class IdempotencyKeyConflictError(OperationError):
    """Raised when the same key is presented with a different body.

    Subclasses :class:`mgf.common.exceptions.OperationError` (→ ``AppError``)
    per **EH-01**: every exception raised by library code is on the typed
    hierarchy, so a top-level handler can map it to a typed exit/HTTP code.

    The Stripe convention: a key represents a logical operation.
    Presenting the same key with a NEW body (different request
    shape) is almost always a client bug — replaying the cached
    response would succeed-but-with-the-wrong-result.

    Carries:
      - ``key`` — the colliding idempotency key.
      - ``expected_body_hash`` — the hash from the original
        request (when stored).
      - ``actual_body_hash`` — the hash from the current
        request.
    """

    def __init__(
        self,
        *,
        key: str,
        expected_body_hash: str,
        actual_body_hash: str,
    ) -> None:
        self.key = key
        self.expected_body_hash = expected_body_hash
        self.actual_body_hash = actual_body_hash
        super().__init__(
            f"idempotency-key {key!r} conflict: "
            f"original body hash {expected_body_hash[:12]}... but "
            f"replay body hash {actual_body_hash[:12]}...",
        )


@dataclass(frozen=True)
class IdempotencyRecord:
    """A stored idempotency record. Returned by :meth:`get`.

    Carries everything needed to replay the original response
    byte-identical: status code, headers, body. Plus the
    ``body_hash`` of the ORIGINAL request (for conflict
    detection) and ``created_at`` (for TTL).
    """

    key: str
    body_hash: str
    status_code: int
    body: Any  # JSON-serializable; consumer's contract.
    headers: dict[str, str]
    created_at: float
    """Monotonic time of original storage. TTL eviction reference."""


@runtime_checkable
class IdempotencyStore(Protocol):
    """Idempotency store Protocol.

    Implementations decide the backing store (in-memory dict,
    Redis HASH, Postgres row...). The contract:

    - :meth:`put` stores a record under the key.
    - :meth:`get` retrieves the record + raises
      :class:`IdempotencyKeyConflictError` on body-hash mismatch.
    - Records are TTL-bounded (~24h default per Stripe
      convention).
    """

    def get(
        self,
        key: str,
        *,
        expected_body_hash: str,
    ) -> IdempotencyRecord | None:
        """Look up an existing record for ``key``.

        Returns the cached record if present AND the
        body-hash matches. Returns ``None`` if no record (the
        caller should proceed with the actual operation).
        Raises :class:`IdempotencyKeyConflictError` if a record
        exists but the body-hash differs.
        """
        ...

    def put(
        self,
        key: str,
        *,
        body_hash: str,
        status_code: int,
        body: Any,
        headers: dict[str, str] | None = None,
    ) -> IdempotencyRecord:
        """Store a record for ``key``.

        Idempotent at the store level — calling :meth:`put`
        twice with the same key + body_hash overwrites
        identically. The middleware-side flow is "get → if
        None, perform op + put".
        """
        ...


class InMemoryIdempotencyStore:
    """In-memory thread-safe idempotency store. Default backend.

    TTL-bounded by ``ttl_seconds`` (default 86400 = 24h per
    Stripe convention). Expired records are evicted lazily on
    :meth:`get` access.

    NOT suitable for multi-replica deployments — each replica
    has its own store. Production wiring uses a Redis-backed
    impl on the same Protocol.

    Parameters
    ----------
    ttl_seconds
        Time-to-live for records. After this, :meth:`get` for
        the key returns ``None`` AND the record is evicted.
    time_source
        Monotonic clock used for TTL accounting. Defaults to
        :func:`time.monotonic`. Injectable so tests can advance time
        deterministically (TTL eviction is otherwise untestable
        hermetically). MUST be non-decreasing.
    """

    def __init__(
        self,
        *,
        ttl_seconds: float = 86_400.0,
        time_source: Callable[[], float] = time.monotonic,
    ) -> None:
        if ttl_seconds <= 0:
            raise ValueError(
                f"ttl_seconds must be > 0; got {ttl_seconds}",
            )
        self._ttl = float(ttl_seconds)
        self._now = time_source
        self._records: dict[str, IdempotencyRecord] = {}
        self._lock = threading.Lock()

    def get(
        self,
        key: str,
        *,
        expected_body_hash: str,
    ) -> IdempotencyRecord | None:
        """Look up the record for ``key``, or ``None`` if absent/expired.

        ``expected_body_hash`` is the hash of the **current (incoming)**
        request's body — i.e. the body the caller expects this key to
        match. If a record exists under ``key`` but was stored with a
        DIFFERENT body, :class:`IdempotencyKeyConflictError` is raised.

        Naming caveat: on that exception the field ``expected_body_hash``
        means the **original/stored** hash and ``actual_body_hash`` means
        the **current** one — the opposite framing from this parameter
        (the exception is written from the store's point of view: "I
        expected the original; I actually got this"). The values are
        populated correctly; only the word "expected" is reused with
        opposite reference on the two APIs.
        """
        now = self._now()
        with self._lock:
            record = self._records.get(key)
            if record is None:
                return None
            # TTL check.
            if (now - record.created_at) > self._ttl:
                del self._records[key]
                return None
            # Body-hash check. NOTE the deliberate mapping: the exception's
            # ``expected`` = the ORIGINAL stored hash (record.body_hash),
            # ``actual`` = this request's hash (the expected_body_hash arg).
            if record.body_hash != expected_body_hash:
                raise IdempotencyKeyConflictError(
                    key=key,
                    expected_body_hash=record.body_hash,
                    actual_body_hash=expected_body_hash,
                )
            return record

    def put(
        self,
        key: str,
        *,
        body_hash: str,
        status_code: int,
        body: Any,
        headers: dict[str, str] | None = None,
    ) -> IdempotencyRecord:
        now = self._now()
        record = IdempotencyRecord(
            key=key,
            body_hash=body_hash,
            status_code=status_code,
            body=body,
            headers=dict(headers) if headers else {},
            created_at=now,
        )
        with self._lock:
            self._records[key] = record
        return record

    def __len__(self) -> int:
        """Number of records currently stored. Test ergonomics."""
        with self._lock:
            return len(self._records)

    def clear(self) -> None:
        """Drop all records. Test ergonomics."""
        with self._lock:
            self._records.clear()
