"""File-based cross-process rate limit coordinator (D-11, D-13).

Each REST consumer (sidecar, kintsugi, seeder) reports its per-window
weight usage to a shared JSON file.  Before acquiring budget, consumers
read aggregate state to respect the IP-wide 6000 weight/min limit.

State file uses atomic rename (tempfile + os.rename) to prevent partial
reads from concurrent consumers.
"""

from __future__ import annotations

import contextlib
import json
import logging
import os
import tempfile
import time
from pathlib import Path

logger = logging.getLogger(__name__)

# ── Budget allocation per D-13 ───────────────────────────────────────

BUDGET_SIDECAR: int = 2400
BUDGET_KINTSUGI: int = 2000
BUDGET_SEEDER: int = 800
BUDGET_RESERVE: int = 800
BUDGET_TOTAL: int = 6000

# ── Timing constants ─────────────────────────────────────────────────

WINDOW_SECONDS: int = 60
STALE_GRACE_SECONDS: int = 5  # entries older than WINDOW + GRACE are ignored

# ── Default state file path ──────────────────────────────────────────

DEFAULT_STATE_PATH = Path("/tmp/opendeviationbar-ratelimit.json")


class RateLimitCoordinator:
    """Cross-process rate limit state sharing via atomic JSON file.

    Each consumer calls ``report_usage()`` after REST calls to record
    weight consumed.  Before acquiring new budget, call ``can_acquire()``
    to check whether the aggregate across all consumers allows it.
    """

    def __init__(self, state_path: Path | None = None) -> None:
        self.state_path = state_path or DEFAULT_STATE_PATH

    # ── Public API ───────────────────────────────────────────────────

    def report_usage(self, consumer_id: str, weight_used: int) -> None:
        """Report this consumer's weight usage to the shared state file.

        Creates or updates the state file atomically.  Never raises --
        logs a warning on failure so the caller is never blocked.
        """
        try:
            state = self._read_state()

            # Reset if window expired
            now = time.time()
            if now - state.get("window_start", 0) > WINDOW_SECONDS:
                state = {"consumers": {}, "window_start": now}

            state.setdefault("consumers", {})[consumer_id] = {
                "weight_used": weight_used,
                "updated": now,
            }

            self._atomic_write(state)
        except Exception:
            logger.warning(
                "Failed to report rate limit usage for %s", consumer_id, exc_info=True
            )

    def get_aggregate_usage(self) -> int:
        """Return total weight across all consumers in the current window.

        Returns 0 if the state file is missing, corrupt, or the window
        has expired.
        """
        state = self._read_state()
        if not state:
            return 0

        now = time.time()
        window_start = state.get("window_start", 0)
        if now - window_start > WINDOW_SECONDS:
            return 0

        total = 0
        for entry in state.get("consumers", {}).values():
            updated = entry.get("updated", 0)
            if now - updated < WINDOW_SECONDS + STALE_GRACE_SECONDS:
                total += entry.get("weight_used", 0)
        return total

    def can_acquire(self, consumer_id: str, weight: int, budget: int) -> bool:  # noqa: ARG002
        """Check whether acquiring ``weight`` units would exceed ``budget``."""
        return self.get_aggregate_usage() + weight <= budget

    def get_consumer_usage(self, consumer_id: str) -> int:
        """Return this consumer's weight usage (0 if not found or expired)."""
        state = self._read_state()
        if not state:
            return 0

        now = time.time()
        window_start = state.get("window_start", 0)
        if now - window_start > WINDOW_SECONDS:
            return 0

        entry = state.get("consumers", {}).get(consumer_id)
        if entry is None:
            return 0
        if now - entry.get("updated", 0) >= WINDOW_SECONDS + STALE_GRACE_SECONDS:
            return 0
        return entry.get("weight_used", 0)

    def get_state(self) -> dict:
        """Return the full state dict (for heartbeat telemetry).

        Returns empty dict if state file is missing or corrupt.
        """
        return self._read_state()

    # ── Internal helpers ─────────────────────────────────────────────

    def _read_state(self) -> dict:
        """Read and parse the state file.  Returns {} on any error."""
        try:
            with self.state_path.open() as f:
                return json.load(f)
        except (FileNotFoundError, json.JSONDecodeError, OSError):
            return {}

    def _atomic_write(self, state: dict) -> None:
        """Write state via tempfile + fsync + rename (atomic on POSIX)."""
        parent = str(self.state_path.parent)
        fd, tmp = tempfile.mkstemp(dir=parent, prefix=".ratelimit_", suffix=".json")
        try:
            os.write(fd, json.dumps(state).encode())
            os.fsync(fd)
            os.close(fd)
            Path(tmp).rename(self.state_path)
        except Exception:
            with contextlib.suppress(OSError):
                os.close(fd)
            with contextlib.suppress(OSError):
                Path(tmp).unlink()
            raise
