#!python
"""sandbroker-pushd - Web Push sender for sandbroker approval requests.

An ADDITIONAL, read-only consumer of the same authz notify spool the approver
watches. Whenever the daemon drops a `<sid>.json` notify (a request needs a human
Windows Hello approval -- plain confirm-tier OR an AI-auditor escalation, both
funnel through this one spool), pushd sends a Web Push to every browser
subscription so Grayson gets an OS/browser notification even when the dashboard
tab isn't in front of him.

Trust + secrecy:
  * Runs as gray in the claude-broker group (same sg-launch as the approver), so
    it can READ the 2770 sandbroker:claude-broker notify spool. It only reads.
  * The notify file carries only non-secret metadata. The push PAYLOAD is a
    strict subset of that -- verb, vault alias, justification, env label -- plus a
    dashboard URL. It NEVER contains the one-time code, the code_hash, the ref,
    or any secret value. (The payload is also end-to-end encrypted to the
    browser by Web Push; the push service can't read it either.)
  * Web Push crypto (ECDH/HKDF/AES-GCM + VAPID) is delegated to pywebpush +
    py-vapid. We do not hand-roll any of it.

Dedupe / restart-safety: fires once per (sid, ts). Seen keys are persisted so a
restart doesn't re-fire for notifies that are still on disk.

Paths:
  SANDBROKER_BASE            base (default /opt/sandbroker; dev: the dev base).
                           notify spool = <base>/run/authz/notify, private key =
                           <base>/etc/vapid_private.pem.
  SANDBROKER_PUSH_STATE      gray-writable dir for subscriptions + seen state
                           (default $XDG_STATE_HOME/sandbroker-push). It is NOT
                           under <base> because prod <base>/run is sandbroker-owned
                           and not gray-writable; the dashboard (also gray) writes
                           subscriptions here and pushd reads/prunes them.
  SANDBROKER_PUSH_SUBJECT    VAPID `sub` claim (default mailto:gray@grayada.ms).
  SANDBROKER_DASHBOARD_URL_FILE  the dashboard's launch-URL file (0600 gray); its
                           current bearer-tokened URL is embedded as the click
                           target so the notification opens a working console.

Run under the repo venv (pywebpush lives there); the shim re-execs into it.
"""
import json
import os
import sys
import time
from pathlib import Path


def _reexec_into_venv() -> None:
    try:
        import pywebpush  # noqa: F401
        return
    except ImportError:
        pass
    venv_root = Path(__file__).resolve().parent.parent / ".venv"
    venv_py = venv_root / "bin" / "python"
    # The venv python is often a symlink to the system python3, so compare venv
    # ROOTS (sys.prefix), not resolved binaries, to detect "already in the venv".
    if venv_py.exists() and Path(sys.prefix).resolve() != venv_root.resolve():
        os.execv(str(venv_py), [str(venv_py), os.path.abspath(__file__), *sys.argv[1:]])
    sys.stderr.write(
        "sandbroker-pushd: pywebpush missing and no repo venv at %s\n"
        "  create it: python3 -m venv %s && %s/bin/pip install pywebpush py-vapid\n"
        % (venv_py, venv_py.parent.parent, venv_py.parent.parent))
    raise SystemExit(2)


_reexec_into_venv()

from pywebpush import webpush, WebPushException  # noqa: E402

BASE = os.environ.get("SANDBROKER_BASE", "/opt/sandbroker")
NOTIFY_DIR = os.path.join(BASE, "run", "authz", "notify")
VAPID_PRIVATE = os.path.join(BASE, "etc", "vapid_private.pem")

STATE_DIR = os.environ.get(
    "SANDBROKER_PUSH_STATE",
    os.path.join(os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")),
                 "sandbroker-push"))
SUBS_FILE = os.path.join(STATE_DIR, "subscriptions.json")
SEEN_FILE = os.path.join(STATE_DIR, "seen.json")

VAPID_SUBJECT = os.environ.get("SANDBROKER_PUSH_SUBJECT", "mailto:gray@grayada.ms")
URL_FILE = os.environ.get(
    "SANDBROKER_DASHBOARD_URL_FILE",
    os.path.join(os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")),
                 "sandbroker-dashboard.url"))

POLL = 0.5
TTL = 900              # push TTL: hold at the push service up to 15 min
SEEN_MAX = 1000        # bound the persisted dedupe set


def _read_json(path, default):
    try:
        with open(path) as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return default


def _atomic_write_json(path, obj, mode=0o600):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
    with os.fdopen(fd, "w") as fh:
        json.dump(obj, fh)
    os.replace(tmp, path)


def load_subscriptions() -> dict:
    """endpoint -> PushSubscription dict. The dashboard writes this file (0600
    gray); we read it every poll so newly-added browsers pick up live."""
    subs = _read_json(SUBS_FILE, {})
    return subs if isinstance(subs, dict) else {}


def prune_subscriptions(dead_endpoints) -> None:
    if not dead_endpoints:
        return
    subs = load_subscriptions()
    for ep in dead_endpoints:
        subs.pop(ep, None)
    _atomic_write_json(SUBS_FILE, subs)
    sys.stderr.write("sandbroker-pushd: pruned %d gone subscription(s)\n" % len(dead_endpoints))


def dashboard_url() -> str:
    """The current bearer-tokened dashboard URL (so a click opens a working
    console). Falls back to the loopback root if the launch-URL file is absent."""
    try:
        with open(URL_FILE) as fh:
            u = fh.read().strip()
            if u:
                return u
    except OSError:
        pass
    return "http://127.0.0.1:8765/"


def build_payload(meta: dict) -> dict:
    """The push body. STRICT non-secret subset of the notify metadata. Never
    includes code, code_hash, ref, or any secret value."""
    verb = str(meta.get("verb", "?"))
    vault = str(meta.get("vault", "?"))
    just = str(meta.get("justification", "") or "").strip()
    env = str(meta.get("env_label", "") or "")
    body = "%s on %s" % (verb, vault)
    if just:
        body += ": " + just
    title = "sandbroker: approval needed"
    if env:
        title = "sandbroker [%s]: approval needed" % env
    return {
        "title": title,
        "body": body,
        "url": dashboard_url(),
        "tag": "sandbroker-approval-" + str(meta.get("sid", "")),
        "ts": meta.get("ts"),
    }


def send_to_all(payload: dict) -> None:
    """Deliver `payload` to every stored subscription; prune 404/410 Gone."""
    subs = load_subscriptions()
    if not subs:
        sys.stderr.write("sandbroker-pushd: notify with no subscriptions stored; nothing to send\n")
        return
    data = json.dumps(payload)
    dead = []
    sent = 0
    for endpoint, sub in list(subs.items()):
        try:
            webpush(
                subscription_info=sub,
                data=data,
                vapid_private_key=VAPID_PRIVATE,
                vapid_claims={"sub": VAPID_SUBJECT},
                ttl=TTL,
            )
            sent += 1
        except WebPushException as exc:
            status = getattr(getattr(exc, "response", None), "status_code", None)
            if status in (404, 410):
                dead.append(endpoint)
            else:
                sys.stderr.write("sandbroker-pushd: push failed (%s): %s\n" % (status, exc))
        except Exception as exc:  # never let one bad sub kill the loop
            sys.stderr.write("sandbroker-pushd: push error: %s\n" % exc)
    prune_subscriptions(dead)
    sys.stderr.write("sandbroker-pushd: sent %d push(es)%s\n"
                     % (sent, (", pruned %d" % len(dead)) if dead else ""))


def _seen_key(sid: str, ts) -> str:
    return "%s:%s" % (sid, ts)


def load_seen() -> list:
    seen = _read_json(SEEN_FILE, [])
    return seen if isinstance(seen, list) else []


def poll_once(seen: list) -> list:
    """Scan the spool; push for each new (sid, ts). Returns updated seen list."""
    try:
        names = [n for n in os.listdir(NOTIFY_DIR) if n.endswith(".json")]
    except OSError:
        return seen
    seen_set = set(seen)
    changed = False
    for name in names:
        sid = name[:-5]
        meta = _read_json(os.path.join(NOTIFY_DIR, name), None)
        if not isinstance(meta, dict):
            continue  # racing approver deletion / partial write; retry next poll
        meta.setdefault("sid", sid)
        key = _seen_key(sid, meta.get("ts"))
        if key in seen_set:
            continue
        try:
            send_to_all(build_payload(meta))
        except Exception as exc:
            sys.stderr.write("sandbroker-pushd: send_to_all failed for %s: %s\n" % (sid, exc))
            continue
        seen.append(key)
        seen_set.add(key)
        changed = True
    if changed:
        if len(seen) > SEEN_MAX:
            seen = seen[-SEEN_MAX:]
        try:
            _atomic_write_json(SEEN_FILE, seen)
        except OSError as exc:
            sys.stderr.write("sandbroker-pushd: cannot persist seen state: %s\n" % exc)
    return seen


def main() -> int:
    os.makedirs(STATE_DIR, exist_ok=True)
    if not os.path.exists(VAPID_PRIVATE):
        sys.stderr.write("sandbroker-pushd: no VAPID key at %s (run sandbroker-vapid-init); "
                         "watching anyway, sends will no-op until it exists\n" % VAPID_PRIVATE)
    sys.stderr.write("sandbroker-pushd watching %s (state %s)\n" % (NOTIFY_DIR, STATE_DIR))
    sys.stderr.flush()
    seen = load_seen()
    while True:
        try:
            seen = poll_once(seen)
        except Exception as exc:
            sys.stderr.write("sandbroker-pushd: poll error: %s\n" % exc)
        time.sleep(POLL)
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        pass
