#!/usr/bin/env python3
"""Ahel autoheal — NOTIFY-FIRST self-healing receiver (M1, :9096).

Canonical config-as-code; deployed to /Users/admin/.ahel/autoheal-notify.py and
run as the root LaunchDaemon com.ahel.autoheal (see com.ahel.autoheal.plist).

SAFETY INVARIANT — nothing restarts autonomously:
  * POST /            (Alertmanager webhook) ONLY publishes an ntfy notification
                      with an "http" action button. It NEVER restarts anything.
  * POST /restart     ONLY runs when a human taps the ntfy button (or curls it)
                      WITH the shared token. Alertmanager can never reach it.
So the single path to a restart is an explicit human tap. There is no timer, no
retry loop, no code path where receiving an alert triggers a restart.

Whitelist is closed: exactly pool-main, pool-worker, bifrost, fleet-tools.
Every restart goes through the audited fleet-tools relay (never a direct SSH),
mirroring ahel-gui/lib/fleet.js restartCommand() verb-for-verb.

Token: read fresh per /restart from TOKEN_FILE (0600, admin-owned, NOT in the
repo). The ntfy action URL carries it in the query string — acceptable because
this server binds the homelab LAN/Tailscale only.
"""
import hmac
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# ---------------------------------------------------------------- config
LISTEN_HOST = "0.0.0.0"          # LAN + Tailscale; the phone taps 192.168.8.168:9096
LISTEN_PORT = 9096
FLEET_TOOLS = "http://192.168.8.168:8090/run_command"
NTFY_BASE = "http://127.0.0.1:8099"      # JSON publish endpoint (root)
NTFY_TOPIC = "erebor"
TOKEN_FILE = os.environ.get(
    "AHEL_AUTOHEAL_TOKEN_FILE", "/Users/admin/.ahel/autoheal.token")

# Closed whitelist. kind mirrors config.js/fleet.js:
#   daemon -> `launchctl kickstart -k system/<target>`  (sudo)
#   agent  -> `launchctl kickstart -k gui/501/<target>` (no sudo)
WHITELIST = {
    "poolmain":   {"node": "m4a", "target": "com.ahel.pool-main",   "kind": "daemon", "label": "pool-main"},
    "poolworker": {"node": "m4b", "target": "com.ahel.pool-worker", "kind": "daemon", "label": "pool-worker"},
    "bifrost":    {"node": "m1",  "target": "com.ahel.bifrost",     "kind": "agent",  "label": "bifrost"},
    "fleettools": {"node": "m1",  "target": "com.ahel.fleet-tools", "kind": "agent",  "label": "fleet-tools"},
}

RESTART_RATE_LIMIT_S = 60        # ignore repeat taps of the same svc within 60s
PUBLISH_DEDUP_S = 300            # don't republish the same firing button within 5m

_restart_lock = threading.Lock()
_last_restart = {}               # svc -> monotonic ts of last accepted restart
_publish_lock = threading.Lock()
_last_publish = {}               # svc -> monotonic ts of last published button


def log(msg):
    sys.stdout.write("[autoheal] %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), msg))
    sys.stdout.flush()


def read_token():
    """Fresh read each call so the token can be rotated without a restart."""
    try:
        with open(TOKEN_FILE, "r") as f:
            return f.read().strip()
    except OSError as e:
        log("token file unreadable: %s" % e)
        return ""


# ------------------------------------------------------------- ntfy publish
def ntfy_publish(payload):
    """POST a JSON message (may include an actions array) to the ntfy root."""
    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        NTFY_BASE + "/", data=body,
        headers={"Content-Type": "application/json"}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=8) as r:
            return r.status == 200
    except Exception as e:  # noqa: BLE001 — never let ntfy failure crash a request
        log("ntfy publish failed: %s" % e)
        return False


def ntfy_text(title, message, priority=3, tags=None):
    ntfy_publish({
        "topic": NTFY_TOPIC, "title": title, "message": message,
        "priority": priority, "tags": tags or [],
    })


def publish_restart_button(svc, node, summary):
    """Publish the actionable notification. This is the ONLY thing the webhook
    does — it hands the human a button; it does not restart."""
    meta = WHITELIST[svc]
    token = read_token()
    if not token:
        # No token → publish a plain warning (a button would be un-actionable).
        ntfy_text("Ahel: %s down" % meta["label"],
                  "%s\n(autoheal token missing — restart from the app)" % summary,
                  priority=4, tags=["warning"])
        return
    qs = urllib.parse.urlencode({"svc": svc, "node": node, "token": token})
    action_url = "http://192.168.8.168:%d/restart?%s" % (LISTEN_PORT, qs)
    ntfy_publish({
        "topic": NTFY_TOPIC,
        "title": "Ahel: %s is DOWN" % meta["label"],
        "message": "%s\nTap to restart via fleet-tools (audited)." % summary,
        "priority": 4,
        "tags": ["warning", "rotating_light"],
        "actions": [{
            "action": "http",
            "label": "Restart %s" % meta["label"],
            "method": "POST",
            "url": action_url,
            "clear": True,
        }],
    })
    log("published restart button for svc=%s node=%s" % (svc, node))


# ------------------------------------------------------------- fleet-tools
def fleet_restart(svc):
    """Run the audited fleet-tools restart. Returns (ok, detail)."""
    meta = WHITELIST[svc]
    if meta["kind"] == "daemon":
        command = "launchctl kickstart -k system/%s" % meta["target"]
        sudo = True
    else:  # agent
        command = "launchctl kickstart -k gui/501/%s" % meta["target"]
        sudo = False
    body = json.dumps({"node": meta["node"], "command": command, "sudo": sudo}).encode()
    req = urllib.request.Request(
        FLEET_TOOLS, data=body,
        headers={"Content-Type": "application/json"}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            data = json.loads(r.read().decode())
        rc = data.get("exit_code")
        if rc == 0:
            return True, "kickstart ok"
        # Bouncing fleet-tools THROUGH fleet-tools SIGKILLs the relay mid-command,
        # so the kickstart can never report a clean rc — the relay returns rc=255
        # (dropped ssh) even though the restart succeeds. Expected, not a failure.
        if svc == "fleettools":
            return True, "issued (relay self-terminated, rc=%s — expected; verify fleet-tools is back)" % rc
        return False, "rc=%s %s" % (rc, str(data.get("output", ""))[:200])
    except urllib.error.URLError as e:
        # Same self-restart artifact, seen instead as a dropped connection.
        if svc == "fleettools":
            return True, "issued (relay connection dropped — expected while bouncing fleet-tools)"
        return False, "fleet-tools unreachable: %s" % e
    except Exception as e:  # noqa: BLE001
        return False, "error: %s" % e


# ------------------------------------------------------------- HTTP handler
class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _send(self, code, text):
        body = (text + "\n").encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        try:
            self.wfile.write(body)
        except Exception:  # noqa: BLE001 — client may have gone away
            pass

    def log_message(self, fmt, *args):  # route through our logger, quietly
        pass

    def do_GET(self):
        path = urllib.parse.urlparse(self.path).path
        if path == "/healthz":
            self._send(200, "ok")
        else:
            self._send(404, "not found")

    def do_POST(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path == "/":
            self._handle_webhook()
        elif parsed.path == "/restart":
            self._handle_restart(urllib.parse.parse_qs(parsed.query))
        else:
            self._send(404, "not found")

    # ---- POST /  (Alertmanager webhook) — NOTIFY ONLY, never restarts --------
    def _handle_webhook(self):
        try:
            n = int(self.headers.get("Content-Length", 0))
            payload = json.loads(self.rfile.read(n).decode("utf-8")) if n else {}
        except Exception as e:  # noqa: BLE001
            self._send(400, "bad json: %s" % e)
            return
        published = 0
        for alert in payload.get("alerts", []):
            if alert.get("status") != "firing":
                continue
            labels = alert.get("labels", {}) or {}
            if labels.get("alertname") != "ServiceDown":
                continue
            svc = labels.get("svc", "")
            node = labels.get("node", "")
            meta = WHITELIST.get(svc)
            if not meta or meta["node"] != node:
                log("ignoring non-whitelisted/ mismatched svc=%r node=%r" % (svc, node))
                continue
            now = time.monotonic()
            with _publish_lock:
                # NB: on macOS time.monotonic() starts near 0 at process start,
                # so a 0 default would falsely dedup the first call within the
                # window — use None to mean "never published".
                last = _last_publish.get(svc)
                if last is not None and now - last < PUBLISH_DEDUP_S:
                    log("dedup: skip republish for svc=%s" % svc)
                    continue
                _last_publish[svc] = now
            summary = (alert.get("annotations", {}) or {}).get(
                "summary", "%s target down on %s" % (meta["label"], node))
            publish_restart_button(svc, node, summary)
            published += 1
        self._send(200, "published %d" % published)

    # ---- POST /restart  — explicit human tap ONLY ---------------------------
    def _handle_restart(self, q):
        svc = (q.get("svc", [""])[0]).strip()
        node = (q.get("node", [""])[0]).strip()
        token = (q.get("token", [""])[0]).strip()

        expected = read_token()
        if not expected or not token or not hmac.compare_digest(token, expected):
            log("REJECT restart svc=%r node=%r — bad/missing token" % (svc, node))
            self._send(403, "forbidden: bad token")
            return
        meta = WHITELIST.get(svc)
        if not meta or meta["node"] != node:
            log("REJECT restart svc=%r node=%r — not whitelisted" % (svc, node))
            self._send(400, "not whitelisted")
            return

        now = time.monotonic()
        with _restart_lock:
            # None sentinel (not 0) — macOS monotonic starts near 0 at process
            # start, so a 0 default would falsely rate-limit the first tap.
            last = _last_restart.get(svc)
            if last is not None and now - last < RESTART_RATE_LIMIT_S:
                log("rate-limited restart svc=%s (%.0fs ago)" % (svc, now - last))
                self._send(429, "rate-limited: ignoring repeat tap")
                return
            _last_restart[svc] = now

        log("RESTART accepted svc=%s node=%s (human tap)" % (svc, node))
        ok, detail = fleet_restart(svc)
        if ok:
            ntfy_text("Ahel: %s restarted" % meta["label"],
                      "Restart issued via fleet-tools (%s)." % detail,
                      priority=3, tags=["white_check_mark"])
            self._send(200, "restarted %s (%s)" % (svc, detail))
        else:
            ntfy_text("Ahel: %s restart FAILED" % meta["label"],
                      detail, priority=5, tags=["x"])
            self._send(502, "restart failed: %s" % detail)


def main():
    srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
    log("listening on %s:%d (whitelist: %s)" % (
        LISTEN_HOST, LISTEN_PORT, ", ".join(sorted(WHITELIST))))
    log("token file: %s (present=%s)" % (TOKEN_FILE, bool(read_token())))
    srv.serve_forever()


if __name__ == "__main__":
    main()
