"""Vault-level process singleton lock.

Architectural primitive that ensures **at most one** AgntSpace process
owns a given vault at a time. Closes the orphan-process class of bug
where a previous server's asyncio loop kept running (channel polling,
heartbeat, work queue, ...) after its uvicorn died — producing
duplicate Telegram polls, duplicate cron firings, duplicate database
writers, and the "two server instances racing on the same bot token"
flood that motivated this module on 2026-04-26.

Design choices and what they're defending against:

- **Lock file at ``<vault>/agntspace/.lock``**, JSON-encoded:
  ``{pid, started_at, host, version, heartbeat_at}``. Stored INSIDE the
  vault (not in app_dir) because the lock is per-vault: switching
  vaults gets a different lock. Two AgntSpace processes attached to
  different vaults are fine and expected.

- **Identity is ``(pid, started_at)``, never just PID.** PIDs get
  recycled on long-running systems; if our process exits and the OS
  hands the same PID to a totally unrelated program, we must not
  mistake it for ourselves. ``started_at`` (process creation timestamp,
  ISO-8601) makes the identity unique across PID reuse.

- **Heartbeat-based liveness, not file locks.** Windows has no
  advisory file locks comparable to POSIX ``flock``; the cross-platform
  story is ugly. Instead the owner writes ``heartbeat_at`` every
  ``HEARTBEAT_INTERVAL`` seconds and a stale heartbeat (older than
  ``STALE_THRESHOLD`` seconds) means the previous owner is dead.
  Simple, portable, and the staleness window is the only correctness
  knob: too short → false-positive theft from a slow heartbeat tick;
  too long → orphan keeps running for too long. 30s is a good middle.

- **Atomic writes via tmp + replace.** ``Path.write_text`` is NOT
  atomic — a crash mid-write could corrupt the lock file. Always
  ``write_text(...)`` to ``<lock>.tmp`` then ``Path.replace(...)`` to
  rename. ``Path.replace`` is atomic on POSIX and best-effort on
  Windows (atomic across the same filesystem, which it always is).

- **Self-check on heartbeat: if our identity stops matching the file,
  we lost the lock.** This is how we detect being stolen — another
  process saw our heartbeat go stale (e.g. asyncio loop blocked) and
  took over. The legitimate response is to shut ourselves down
  gracefully, not to fight back. Returning the loss as a callback
  lets the caller (lifespan) flip a flag that channels + scheduler
  observe.

- **Lock release is best-effort.** ``release()`` deletes the file
  on graceful shutdown; abrupt deaths leave it orphaned. That's
  fine — the next boot will see a stale heartbeat and reclaim it.

- **Setup mode is exempt.** A vault that doesn't exist yet has no
  ``agntspace/`` directory; the caller must skip lock acquisition
  entirely until setup completes. ``acquire`` returns ``None`` when
  the lock directory can't be created.

This file is pure (no FastAPI, no agntspace cross-imports) so it can
be tested in isolation and reused from CLI commands.
"""

from __future__ import annotations

import json
import logging
import os
import socket
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

log = logging.getLogger(__name__)

# Heartbeat cadence + staleness threshold. The threshold is 3x the
# cadence so a single missed update doesn't trigger false theft.
HEARTBEAT_INTERVAL: float = 10.0  # seconds
STALE_THRESHOLD: float = 30.0  # arch:deterministic — process-lock heartbeat staleness window; 3× HEARTBEAT_INTERVAL by design, infrastructure not strategy

# Process-wide singleton handle so multiple acquire() calls don't
# clobber each other inside one process.
_LOCK_FILENAME: str = ".lock"


@dataclass(frozen=True)
class LockIdentity:
    """Stable identity of a lock-owning process.

    PID alone is unsafe (can be recycled). The combination of
    ``pid`` + ``started_at`` is unique across the lifetime of the OS
    install, which is good enough for our purposes.
    """

    pid: int
    started_at: str  # ISO-8601 UTC

    def matches(self, payload: dict[str, Any]) -> bool:
        """True iff the lock payload identifies this same process."""
        try:
            return int(payload.get("pid", -1)) == self.pid and str(
                payload.get("started_at", "")
            ) == self.started_at
        except (TypeError, ValueError):
            return False

    def to_payload(self) -> dict[str, Any]:
        return {
            "pid": self.pid,
            "started_at": self.started_at,
        }


@dataclass
class LockAcquireResult:
    """Outcome of an ``acquire()`` attempt."""

    acquired: bool
    reason: str = ""  # short machine-readable code; empty on success
    holder_pid: int | None = None
    holder_started_at: str = ""
    holder_host: str = ""
    holder_heartbeat_age_seconds: float | None = None


class VaultProcessLock:
    """Per-vault singleton lock backed by a heartbeated lock file.

    Designed for use inside a FastAPI lifespan: ``acquire`` at startup,
    ``heartbeat`` periodically from a background task, ``release`` at
    graceful shutdown. ``check_owned()`` returns False when the lock
    has been stolen — the caller must trigger graceful shutdown.

    Pure: no agntspace cross-imports. Filesystem-only state. Safe to
    reuse from CLI commands for diagnostic purposes.
    """

    def __init__(
        self,
        vault_dir: Path,
        *,
        version: str = "dev",
        identity: LockIdentity | None = None,
        clock: Any = None,
    ) -> None:
        self._vault_dir = Path(vault_dir)
        self._lock_path = self._vault_dir / _LOCK_FILENAME
        self._version = version
        self._identity = identity or _self_identity()
        # Injectable clock for tests. Default uses real wall clock.
        self._clock = clock or _RealClock()

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

    @property
    def lock_path(self) -> Path:
        return self._lock_path

    @property
    def identity(self) -> LockIdentity:
        return self._identity

    def acquire(self) -> LockAcquireResult:
        """Try to acquire the lock. Idempotent: if we already own it, succeeds.

        Returns ``LockAcquireResult(acquired=True)`` when:
          - no lock file exists (fresh acquire)
          - the lock file exists but the holder's heartbeat is stale
            (steal from dead owner)
          - the lock file exists and we are the holder (re-acquire after
            crash with same identity — extremely rare but harmless)

        Returns ``LockAcquireResult(acquired=False, reason="busy")`` when
        another process holds a fresh lock. The caller must NOT proceed
        — that's a different running AgntSpace.

        Returns ``LockAcquireResult(acquired=False, reason="vault_missing")``
        if the vault directory cannot be created (e.g. permission denied).
        Caller should skip the lock entirely (setup mode).
        """
        try:
            self._vault_dir.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            log.debug("ProcessLock: cannot ensure vault dir %s: %s", self._vault_dir, exc)
            return LockAcquireResult(acquired=False, reason="vault_missing")

        existing = self._read_lock_file()
        if existing is not None:
            holder_pid = _safe_int(existing.get("pid"))
            holder_started = str(existing.get("started_at", ""))
            holder_host = str(existing.get("host", ""))
            heartbeat_iso = str(existing.get("heartbeat_at", ""))
            age = self._heartbeat_age_seconds(heartbeat_iso)

            # Re-acquire by same identity (legitimate — process restart
            # with reused PID + started_at, or test harness)
            if self._identity.matches(existing):
                self._write_lock_file()
                return LockAcquireResult(
                    acquired=True,
                    reason="reacquired",
                    holder_pid=holder_pid,
                    holder_started_at=holder_started,
                    holder_host=holder_host,
                    holder_heartbeat_age_seconds=age,
                )

            # Fresh heartbeat → another process is actively running
            if age is not None and age < STALE_THRESHOLD:
                return LockAcquireResult(
                    acquired=False,
                    reason="busy",
                    holder_pid=holder_pid,
                    holder_started_at=holder_started,
                    holder_host=holder_host,
                    holder_heartbeat_age_seconds=age,
                )

            # Stale heartbeat or unparseable: previous owner is dead
            log.warning(
                "ProcessLock: prior owner pid=%s heartbeat is stale (age=%s); reclaiming",
                holder_pid, age,
            )
            self._write_lock_file()
            return LockAcquireResult(
                acquired=True,
                reason="stale_reclaimed",
                holder_pid=holder_pid,
                holder_started_at=holder_started,
                holder_host=holder_host,
                holder_heartbeat_age_seconds=age,
            )

        # No lock file — fresh acquire
        self._write_lock_file()
        return LockAcquireResult(acquired=True, reason="fresh")

    def heartbeat(self) -> bool:
        """Refresh the heartbeat. Returns False if we lost the lock.

        Always re-reads the file before writing so a stolen lock is
        detected immediately (the file's identity won't match ours
        anymore). Returns True if we're still the rightful owner.

        On any I/O failure returns True (assume still owned — better
        to keep running with a stale heartbeat than spuriously shut
        down on a transient disk hiccup).
        """
        existing = self._read_lock_file()
        if existing is None:
            # File got deleted (manual cleanup, vault wipe).
            # Re-write under our identity rather than declaring loss —
            # if someone else wanted it they'd have written first.
            self._write_lock_file()
            return True

        if not self._identity.matches(existing):
            # Lock was stolen.
            holder_pid = _safe_int(existing.get("pid"))
            log.warning(
                "ProcessLock: lock stolen by pid=%s started_at=%s; we are now an orphan",
                holder_pid, existing.get("started_at"),
            )
            return False

        # Still ours — refresh heartbeat.
        self._write_lock_file()
        return True

    def check_owned(self) -> bool:
        """Read-only: are we still the rightful owner?

        Used by lifespan-resident code that wants to verify ownership
        without bumping the heartbeat. Same loss-detection as
        ``heartbeat()`` but doesn't write.
        """
        existing = self._read_lock_file()
        if existing is None:
            return False
        return self._identity.matches(existing)

    def release(self) -> None:
        """Release the lock (delete the file). Best-effort.

        Only deletes if WE still own it — prevents a graceful shutdown
        from clobbering a fresh lock that another process took after
        our heartbeat went stale.
        """
        existing = self._read_lock_file()
        if existing is None:
            return
        if not self._identity.matches(existing):
            log.debug("ProcessLock: release() skipped — lock owned by another process")
            return
        try:
            self._lock_path.unlink()
        except OSError:
            log.debug("ProcessLock: release() failed to unlink", exc_info=True)

    # ─── Internals ────────────────────────────────────────────────

    def _read_lock_file(self) -> dict[str, Any] | None:
        try:
            text = self._lock_path.read_text(encoding="utf-8")
        except FileNotFoundError:
            return None
        except OSError:
            log.debug("ProcessLock: read failed", exc_info=True)
            return None
        try:
            data = json.loads(text)
        except json.JSONDecodeError:
            log.debug("ProcessLock: lock file corrupt (will be overwritten)")
            return None
        if not isinstance(data, dict):
            return None
        return data

    def _write_lock_file(self) -> None:
        payload = {
            **self._identity.to_payload(),
            "host": _safe_hostname(),
            "version": self._version,
            "heartbeat_at": self._clock.now_iso(),
        }
        tmp = self._lock_path.with_suffix(self._lock_path.suffix + ".tmp")
        try:
            tmp.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
            tmp.replace(self._lock_path)
        except OSError:
            log.debug("ProcessLock: write failed", exc_info=True)

    def _heartbeat_age_seconds(self, heartbeat_iso: str) -> float | None:
        if not heartbeat_iso:
            return None
        try:
            hb = datetime.fromisoformat(heartbeat_iso)
        except ValueError:
            return None
        if hb.tzinfo is None:
            hb = hb.replace(tzinfo=UTC)
        now = self._clock.now()
        return (now - hb).total_seconds()


# ─── Helpers ──────────────────────────────────────────────────────


class _RealClock:
    """Default wall-clock implementation. Tests inject a fake."""

    def now(self) -> datetime:
        return datetime.now(UTC)

    def now_iso(self) -> str:
        return self.now().isoformat()


def _self_identity() -> LockIdentity:
    """Build the current process's lock identity.

    Uses ``psutil.Process(pid).create_time()`` when available, falls
    back to a generated timestamp at first call (constant for the
    process lifetime via module-level cache). The fallback isn't as
    robust against PID reuse but is good enough — PID reuse on a
    second-resolution timer is vanishingly rare.
    """
    pid = os.getpid()
    started_at = _process_creation_time(pid) or _MODULE_BORN_AT
    return LockIdentity(pid=pid, started_at=started_at)


def _process_creation_time(pid: int) -> str | None:
    """Best-effort process creation time as ISO-8601 UTC.

    Tries psutil if available (cross-platform, accurate). Returns
    None on any failure — caller falls back to module-load timestamp.
    """
    try:
        import psutil  # type: ignore
    except ImportError:
        return None
    try:
        proc = psutil.Process(pid)
        return datetime.fromtimestamp(proc.create_time(), tz=UTC).isoformat()
    except Exception:
        return None


def _safe_hostname() -> str:
    try:
        return socket.gethostname()
    except Exception:
        return "unknown"


def _safe_int(val: Any) -> int | None:
    try:
        return int(val)
    except (TypeError, ValueError):
        return None


# Module-load timestamp — fallback when psutil isn't available. Pinned
# at import time so every call to ``_self_identity()`` in this process
# returns the same started_at.
_MODULE_BORN_AT: str = datetime.now(UTC).isoformat()
