"""In-memory audit outbox with retry, redaction, and hash-chain integrity.

Entries are written to an in-memory queue, hash-chained into receipts,
and flushed to the Receipt Vault with exponential backoff. Entries are
never silently dropped — persistent failures are logged to a dead-letter
callback.

Note: the outbox is *not* durable across process restarts. Unflushed
entries are lost if the process terminates. Call ``flush()`` or
``close()`` to ensure all entries are exported before shutdown.
"""

from __future__ import annotations

import logging
import threading
import time
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

import httpx
from authzen_backend.auth import TokenProvider

from aria_agentkit.audit.receipt import AuditReceipt, mint_receipt

logger = logging.getLogger(__name__)

_DEFAULT_REDACT_KEYS = frozenset({"password", "secret", "token", "api_key", "credential"})


@dataclass
class OutboxConfig:
    """Configuration for the audit outbox."""

    receipt_vault_url: str
    auth: TokenProvider | None = None
    timeout: float = 10.0
    max_batch_size: int = 50
    max_retries: int = 5
    base_backoff: float = 1.0
    max_backoff: float = 5.0
    redact_keys: frozenset[str] = _DEFAULT_REDACT_KEYS
    dead_letter_callback: Callable[[list[AuditReceipt], str], None] | None = None


class AuditOutbox:
    """Thread-safe in-memory outbox for audit receipts.

    Entries are appended via ``enqueue()``, hash-chained, and exported
    to Receipt Vault on ``flush()``.

    Args:
        config: Outbox configuration.
        http_client: Optional pre-configured httpx.Client.
        sleep_fn: Sleep function for retry backoff. Defaults to
            ``time.sleep``. Inject a no-op for tests.
    """

    def __init__(
        self,
        config: OutboxConfig,
        http_client: httpx.Client | None = None,
        sleep_fn: Callable[[float], object] | None = None,
    ) -> None:
        self._config = config
        self._owns_client = http_client is None
        self._client = http_client or httpx.Client(timeout=config.timeout)
        self._sleep = sleep_fn or time.sleep

        self._lock = threading.Lock()
        self._queue: deque[AuditReceipt] = deque()
        self._prev_hash = "0" * 64

    def enqueue(self, entry: dict[str, Any]) -> AuditReceipt:
        """Redact, hash-chain, and queue an audit entry.

        Returns the minted receipt for caller correlation.
        """
        redacted = self._redact(entry)
        with self._lock:
            receipt = mint_receipt(redacted, prev_hash=self._prev_hash)
            self._prev_hash = receipt.receipt_hash
            self._queue.append(receipt)
        return receipt

    def flush(self) -> int:
        """Synchronously drain the outbox to Receipt Vault.

        Returns the number of receipts successfully exported.
        """
        exported = 0
        while True:
            batch = self._take_batch()
            if not batch:
                break
            if self._export_with_retry(batch):
                exported += len(batch)
            else:
                self._dead_letter(batch, "max retries exceeded")
        return exported

    def pending_count(self) -> int:
        with self._lock:
            return len(self._queue)

    def _take_batch(self) -> list[AuditReceipt]:
        with self._lock:
            batch: list[AuditReceipt] = []
            for _ in range(min(self._config.max_batch_size, len(self._queue))):
                batch.append(self._queue.popleft())
            return batch

    def _export_with_retry(self, batch: list[AuditReceipt]) -> bool:
        url = self._config.receipt_vault_url.rstrip("/") + "/receipts"
        payload = [r.to_dict() for r in batch]

        for attempt in range(self._config.max_retries):
            try:
                headers = self._build_headers()
                resp = self._client.post(url, json=payload, headers=headers)
                resp.raise_for_status()
                return True
            except Exception:
                wait = min(
                    self._config.base_backoff * (2**attempt),
                    self._config.max_backoff,
                )
                logger.warning(
                    "Receipt Vault export attempt %d/%d failed, retrying in %.1fs",
                    attempt + 1,
                    self._config.max_retries,
                    wait,
                    exc_info=True,
                )
                self._sleep(wait)
        return False

    def _build_headers(self) -> dict[str, str]:
        headers: dict[str, str] = {"Content-Type": "application/json"}
        if self._config.auth is not None:
            headers["Authorization"] = f"Bearer {self._config.auth.get_token(self._client)}"
        return headers

    def _redact(self, entry: dict[str, Any]) -> dict[str, Any]:
        """Recursively redact sensitive keys from an audit entry.

        Since the input is always a dict, the return is always a dict.
        """
        return self._redact_value(entry, self._config.redact_keys)  # type: ignore[no-any-return]

    @staticmethod
    def _redact_value(value: Any, keys: frozenset[str]) -> Any:
        """Recursively redact dicts and lists."""
        if isinstance(value, dict):
            return {
                k: "***REDACTED***" if k.lower() in keys else AuditOutbox._redact_value(v, keys)
                for k, v in value.items()
            }
        if isinstance(value, list):
            return [AuditOutbox._redact_value(item, keys) for item in value]
        return value

    def _dead_letter(self, batch: list[AuditReceipt], reason: str) -> None:
        logger.error(
            "Dead-lettering %d receipts: %s",
            len(batch),
            reason,
        )
        if self._config.dead_letter_callback is not None:
            try:
                self._config.dead_letter_callback(batch, reason)
            except Exception:
                logger.exception("Dead-letter callback failed")

    def close(self) -> None:
        """Flush remaining entries and release HTTP resources."""
        self.flush()
        if self._owns_client:
            self._client.close()
