#!/usr/bin/env python3
"""Register and decide A/B causal probes per gate_id.

Probes record a skip rate for specific gate_ids. The `decide` subcommand
returns a deterministic 'load' or 'skip' verdict by hashing
(session_id || gate_id). Same inputs always yield the same verdict.

Unregistered gates always 'load'.

The probes file is treated like any other operator state write target:
symlinks at the destination are rejected via assert_regular_file_destination.
"""
from __future__ import annotations

import argparse
import contextlib
import hashlib
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from collect_hook_event import assert_regular_file_destination  # noqa: E402
from state_paths import atomic_rewrite  # noqa: E402


@contextlib.contextmanager
def _locked_probes(path: Path):
    """Open the probes file under an exclusive sidecar lock and yield the
    current dict plus a setter that commits the new state atomically.
    Wraps state_paths.atomic_rewrite so a process death between read and
    write leaves probes.json at its prior content rather than truncating
    it (load_probes' next consumer would otherwise raise JSONDecodeError
    and all probe decisions would go offline)."""
    try:
        assert_regular_file_destination(path, label="Probes file")
    except ValueError as exc:
        print(str(exc), file=sys.stderr)
        sys.exit(2)
    with atomic_rewrite(path, mode=0o600) as (raw, commit_text):
        data = json.loads(raw) if raw.strip() else {}

        def commit(new_data):
            commit_text(json.dumps(new_data, indent=2, sort_keys=True))

        yield data, commit


def load_probes(path: Path) -> dict:
    """Snapshot read for `decide` and `list` — no lock needed."""
    if not path.exists():
        return {}
    return json.loads(path.read_text(encoding="utf-8"))


def decide(gate_id: str, session_id: str, rate: float) -> str:
    # Defend against a hand-edited probes.json that put a nonsense rate in:
    # without this assertion a value of 2.0 silently makes every session skip
    # and -0.1 silently makes every session load, both of which corrupt the
    # cohort math invisibly. The check is at decide() rather than load() so
    # `cmd_list` still works on a malformed file (the operator needs to see
    # what to fix).
    if not isinstance(rate, (int, float)) or not (0.0 <= rate <= 1.0):
        raise ValueError(f"probe rate must be a number in [0.0, 1.0]; got {rate!r}")
    h = hashlib.sha256(f"{session_id}|{gate_id}".encode("utf-8")).hexdigest()
    bucket = int(h[:8], 16) % 10000
    return "skip" if bucket < int(rate * 10000) else "load"


def cmd_register(args):
    if not (0.0 <= args.rate <= 1.0):
        print(f"rate must be between 0.0 and 1.0; got {args.rate}", file=sys.stderr)
        return 2
    # Warn at the degenerate boundaries: rate=0.0 starves the probe_skipped
    # cohort and rate=1.0 starves the probe_loaded cohort, in both cases
    # freezing causal_signal at needs_review forever with no other signal.
    if args.rate == 0.0:
        print(
            "warning: rate=0.0 keeps the probe_skipped cohort empty; "
            "causal_signal will stay needs_review",
            file=sys.stderr,
        )
    elif args.rate == 1.0:
        print(
            "warning: rate=1.0 keeps the probe_loaded cohort empty; "
            "causal_signal will stay needs_review",
            file=sys.stderr,
        )
    with _locked_probes(args.probes) as (data, commit):
        data[args.gate_id] = {"rate": args.rate}
        commit(data)
    return 0


def cmd_unregister(args):
    with _locked_probes(args.probes) as (data, commit):
        data.pop(args.gate_id, None)
        commit(data)
    return 0


def cmd_decide(args):
    data = load_probes(args.probes)
    probe = data.get(args.gate_id)
    if not probe:
        print("load")
        return 0
    try:
        verdict = decide(args.gate_id, args.session_id, probe.get("rate"))
    except ValueError as exc:
        print(f"invalid probe entry for {args.gate_id}: {exc}", file=sys.stderr)
        return 2
    print(verdict)
    return 0


def cmd_list(args):
    data = load_probes(args.probes)
    print(json.dumps(data, indent=2, sort_keys=True))
    return 0


def parse_args():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--probes", required=True, type=Path)
    sub = p.add_subparsers(dest="cmd", required=True)

    r = sub.add_parser("register")
    r.add_argument("--gate-id", required=True)
    r.add_argument("--rate", required=True, type=float)
    r.set_defaults(func=cmd_register)

    u = sub.add_parser("unregister")
    u.add_argument("--gate-id", required=True)
    u.set_defaults(func=cmd_unregister)

    d = sub.add_parser("decide")
    d.add_argument("--gate-id", required=True)
    d.add_argument("--session-id", required=True)
    d.set_defaults(func=cmd_decide)

    lst = sub.add_parser("list")
    lst.set_defaults(func=cmd_list)

    return p.parse_args()


def main():
    args = parse_args()
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
