#!/usr/bin/env python3
"""Revoke a runtime lease from a compaction or lifecycle observer."""

from __future__ import annotations

import argparse
import json
import os
import socket
import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Final

from lease_generation import bump_runtime_generation, read_runtime_generation

MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5


def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
    payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
    if len(payload) > MAX_FRAME:
        raise ValueError("invalid revoke request")
    response = bytearray()
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
        connection.settimeout(BROKER_TIMEOUT_SECONDS)
        connection.connect(str(socket_path))
        connection.sendall(payload)
        connection.shutdown(socket.SHUT_WR)
        while len(response) <= MAX_FRAME:
            chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
            if not chunk:
                break
            response.extend(chunk)
    if len(response) > MAX_FRAME or not response.endswith(b"\n"):
        raise ValueError("invalid broker reply")
    value = json.loads(response)
    if not isinstance(value, dict):
        raise ValueError("invalid broker reply")
    return value


def main(
    argv: Sequence[str] | None = None,
    *,
    environ: Mapping[str, str] | None = None,
    request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
) -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
    parser.add_argument("--reason", required=True)
    parser.add_argument("--bump-generation", action="store_true")
    arguments = parser.parse_args(argv)
    source_environment = os.environ if environ is None else environ

    # D29: a session that never held a lease has nothing to revoke, and that is a
    # SUCCESS, not a failed revocation. The block below is deliberately fail-closed
    # for a broker that is unreachable, which is right — but it cannot distinguish
    # "the broker is down" from "there was never a lease", so a bare-launched
    # session was denied every lifecycle transition, including compaction. Denying
    # compaction protects nothing there; it converts a recoverable context limit
    # into a lost session.
    #
    # Absence must be TOTAL to qualify. If exactly one variable is present the
    # session is half-provisioned, which is real misconfiguration, and it still
    # takes the fail-closed path below.
    lease_variables = ("MOSAIC_LEASE_BROKER_SOCKET", "MOSAIC_LEASE_SESSION_ID")
    present = [name for name in lease_variables if source_environment.get(name)]
    if not present:
        return 0

    try:
        if not arguments.reason or len(arguments.reason) > 128:
            raise ValueError("invalid revoke reason")
        socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
        session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
        if (
            len(session_id) != 64
            or any(character not in "0123456789abcdef" for character in session_id)
        ):
            raise ValueError("invalid broker session")
        generation = (
            bump_runtime_generation(source_environment)
            if arguments.bump_generation
            else read_runtime_generation(source_environment)
        )
        reply = request(
            socket_path,
            {
                "action": "revoke_lease",
                "session_id": session_id,
                "runtime_generation": generation,
                "reason": arguments.reason,
                "runtime": arguments.runtime,
            },
        )
        if reply.get("ok") is not True or reply.get("state") != "UNVERIFIED":
            raise ValueError("revocation refused")
    except (KeyError, ValueError, OSError, json.JSONDecodeError):
        # A fired observer must remain fail-closed even if the broker transport
        # is unavailable. Advancing the private local generation fences every
        # later tool check; the broker revokes the old lease when it next sees
        # that higher generation. Explicit rollover already advanced it above.
        if not arguments.bump_generation:
            try:
                bump_runtime_generation(source_environment)
            except (KeyError, ValueError, OSError):
                pass
        print("Mosaic lease revocation failed; lifecycle transition denied.", file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
