"""Accounting for the Control Room's 5-concurrent-session limit.

A Control Room account may hold only five active sessions; a sixth login evicts
one of the others, which shows up as an unrelated tool call suddenly returning
401. Nothing in the API reports how many slots are in use, so this module keeps
a ledger on disk shared by every process using the account — MCP servers,
one-off scripts, and the debug browser window, which costs a slot of its own.

The ledger is advisory: it reflects what *this machine* is holding. It cannot
see sessions opened elsewhere, so it is a guard against self-inflicted eviction,
not a guarantee.
"""

import os
import time
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Optional

from ..utils.atomic_io import read_json, write_json_atomic
from . import _locks
from ._paths import lock_path, session_ledger_path

MAX_CONCURRENT_SESSIONS = 5
SLOT_STALE_AFTER_SECONDS = 3600.0


class SessionBudgetExceeded(RuntimeError):
    """Raised when claiming another session would risk evicting a live one."""


@dataclass(frozen=True)
class SessionSlot:
    """One held Control Room session. Immutable; renewal returns a new slot."""

    owner: str
    pid: int
    created_at: float
    renewed_at: float

    def renewed(self, *, now: float, pid: int | None = None) -> "SessionSlot":
        """Refresh the check-in time, optionally re-binding to another process.

        Re-binding matters for the debug window: the slot is claimed by the MCP
        server before the launch, but the session belongs to the detached
        Chromium process that outlives it.
        """
        if pid is None:
            return replace(self, renewed_at=now)
        return replace(self, renewed_at=now, pid=pid)

    def is_stale(self, *, now: float, stale_after: float = SLOT_STALE_AFTER_SECONDS) -> bool:
        """A slot is stale when its process is gone, or it stopped checking in."""
        if not _pid_alive(self.pid):
            return True
        return (now - self.renewed_at) > stale_after

    def to_payload(self) -> dict:
        return {
            "owner": self.owner,
            "pid": self.pid,
            "created_at": self.created_at,
            "renewed_at": self.renewed_at,
        }

    @classmethod
    def from_payload(cls, payload: dict) -> Optional["SessionSlot"]:
        try:
            return cls(
                owner=str(payload["owner"]),
                pid=int(payload["pid"]),
                created_at=float(payload["created_at"]),
                renewed_at=float(payload.get("renewed_at", payload["created_at"])),
            )
        except (KeyError, TypeError, ValueError):
            return None


class SessionBudget:
    """Disk-backed ledger of session slots held for one account."""

    def __init__(self, account_key: str, *, limit: int = MAX_CONCURRENT_SESSIONS) -> None:
        self._key = account_key
        self._limit = limit
        self._path: Path = session_ledger_path(account_key)
        self._lock_path: Path = lock_path(account_key, "sessions")

    @property
    def limit(self) -> int:
        return self._limit

    def active(self, *, now: float | None = None) -> tuple[SessionSlot, ...]:
        """Live slots, with stale entries filtered out (read-only)."""
        moment = time.time() if now is None else now
        return tuple(s for s in self._load() if not s.is_stale(now=moment))

    def claim(self, owner: str, *, now: float | None = None, pid: int | None = None) -> SessionSlot:
        """Reserve a slot for `owner`, or raise SessionBudgetExceeded.

        Re-claiming an owner that already holds a slot renews it rather than
        consuming a second one, so a retrying caller cannot exhaust the budget.

        ``pid`` names the process whose liveness decides when the slot goes
        stale; it defaults to the caller. Pass it explicitly when the session
        belongs to a *different* process than the one booking the slot — the
        debug window is claimed by the server but held by detached Chromium.
        """
        moment = time.time() if now is None else now
        with _locks.hold(self._lock_path):
            live = [s for s in self._load() if not s.is_stale(now=moment)]
            existing = next((s for s in live if s.owner == owner), None)
            if existing is not None:
                renewed = existing.renewed(now=moment, pid=pid)
                self._save([renewed if s.owner == owner else s for s in live])
                return renewed
            if len(live) >= self._limit:
                raise SessionBudgetExceeded(
                    f"{len(live)}/{self._limit} Control Room sessions already held for this "
                    f"account. Close another session (or stop an idle a360-mcp process) "
                    f"before opening one more — a 6th login evicts an existing session. "
                    f"Holders: {', '.join(s.owner for s in live)}"
                )
            slot = SessionSlot(
                owner=owner,
                pid=os.getpid() if pid is None else pid,
                created_at=moment,
                renewed_at=moment,
            )
            self._save([*live, slot])
            return slot

    def release(self, owner: str) -> None:
        """Give back `owner`'s slot. Safe to call when nothing is held."""
        with _locks.hold(self._lock_path):
            remaining = [s for s in self._load() if s.owner != owner]
            self._save(remaining)

    def summary(self, *, now: float | None = None) -> dict:
        """Compact accounting for health output."""
        live = self.active(now=now)
        return {
            "used": len(live),
            "limit": self._limit,
            "owners": [s.owner for s in live],
        }

    def _load(self) -> list[SessionSlot]:
        payload = read_json(self._path)
        if not isinstance(payload, dict):
            return []
        entries = payload.get("slots")
        if not isinstance(entries, list):
            return []
        slots = (SessionSlot.from_payload(e) for e in entries if isinstance(e, dict))
        return [s for s in slots if s is not None]

    def _save(self, slots: list[SessionSlot]) -> None:
        write_json_atomic(self._path, {"slots": [s.to_payload() for s in slots]})


def _pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True  # exists, owned by someone else
    except OSError:
        return True  # unknown -> assume alive; never free a slot on a guess
    return True
