#!/usr/bin/env python3
"""Appliance health watchdog (server-macos).

Runs on its OWN launchd schedule (com.alexandria.health-check, every ~30 min) — deliberately
independent of the sweep, so it still fires when the sweep itself is down. It reads the
runner heartbeats written by 2a (runner-status.json / sync-status.json / listener-status.json)
and, when something that WAS running has gone stale or errored, emails the owner — turning a
silent failure into a notification.

Design notes:
  * Reuses digest.py's config + send_email (SMTP from the same off-repo .env).
  * Alerts only on a *regression* — a heartbeat that exists but is stale/errored. An ABSENT
    heartbeat (never written) is benign (fresh setup / optional listener), so it never alerts.
  * Deduplicated via health-alert-state.json: one alert when a problem appears, one "recovered"
    when it clears, and a re-alert of a persistent problem at most every HEALTH_REPEAT_HOURS.
  * Honors the kill switch (SWEEP_OFF / SWEEP_DISABLE): a paused appliance never alerts.

Recipient: HEALTH_ALERT_TO env → config.automation.health_alert_to → owner.emails[0].
"""
import json
import os
import time
import datetime as dt
from pathlib import Path

from alexandria_vault.engine import consume_vault_arg, load_fresh

# `--vault` must land in VAULT_DIR before any import-time config/vault resolution.
consume_vault_arg()

STATEDIR = Path(os.environ.get(
    "SWEEP_STATE", str(Path.home() / "Obsidian" / "secrets" / "sweep-state.json"))).parent
ALERT_STATE = STATEDIR / "health-alert-state.json"
REPEAT_HOURS = int(os.environ.get("HEALTH_REPEAT_HOURS", "12"))

# Staleness thresholds in minutes — kept in step with `alexandria doctor`.
SWEEP_STALE, SYNC_STALE, LISTENER_STALE = 90, 25, 40


def log(msg):
    print(f"[{dt.datetime.now().isoformat(timespec='seconds')}] health_check: {msg}", flush=True)


def _killed():
    if os.environ.get("SWEEP_DISABLE", "0") in ("1", "true"):
        return True
    return (Path.home() / "Obsidian" / "secrets" / "SWEEP_OFF").exists()


def _read(name):
    """(parsed_json_or_{}, age_minutes) for a status file, or (None, None) if absent."""
    f = STATEDIR / name
    if not f.exists():
        return None, None
    age = (time.time() - f.stat().st_mtime) / 60
    try:
        return json.loads(f.read_text()), age
    except Exception:
        return {}, age


def evaluate():
    """Human-readable problems (empty list = healthy). Only flags heartbeats that EXIST but
    have gone bad — an absent heartbeat is a fresh/optional component, not a failure."""
    problems = []
    sweep, age = _read("runner-status.json")
    if sweep is not None:
        if age > SWEEP_STALE:
            problems.append(f"sweep: last run {age / 60:.1f}h ago (expected hourly)")
        elif sweep.get("errors"):
            problems.append(f"sweep: {len(sweep['errors'])} task error(s) — {sweep['errors'][0]}")

    sync, age = _read("sync-status.json")
    if sync is not None:
        if age > SYNC_STALE:
            problems.append(f"vault-sync: last run {age:.0f}m ago (expected every ~10m)")
        elif sync.get("ok") is False:
            problems.append(f"vault-sync: {sync.get('note', 'not ok')}")

    lis, age = _read("listener-status.json")
    if lis is not None and age > LISTENER_STALE:
        problems.append(f"inbox listener: last check-in {age:.0f}m ago")
    return problems


def _load_digest():
    return load_fresh("digest")


def _save_alert_state(alerting):
    try:
        STATEDIR.mkdir(parents=True, exist_ok=True)
        ALERT_STATE.write_text(json.dumps({"alerting": alerting, "at": time.time()}))
    except Exception as e:
        log(f"alert-state write failed: {e}")


def main():
    if _killed():
        log("kill switch active — not checking")
        return
    problems = evaluate()
    try:
        prev = json.loads(ALERT_STATE.read_text())
    except Exception:
        prev = {}
    was_alerting, last_at = bool(prev.get("alerting")), prev.get("at", 0)

    d = _load_digest()
    agent = (d.CONFIG.get("agent") or {}).get("name") or "Your assistant"
    frm = (d.CONFIG.get("agent") or {}).get("email")
    to = (os.environ.get("HEALTH_ALERT_TO")
          or (d.CONFIG.get("automation") or {}).get("health_alert_to")
          or ((d.CONFIG.get("owner") or {}).get("emails") or [None])[0])

    if problems:
        if not to:
            log(f"UNHEALTHY, but no recipient configured: {problems}")
            return
        due = (not was_alerting) or (time.time() - last_at > REPEAT_HOURS * 3600)
        if due:
            items = "".join(f"<li>{p}</li>" for p in problems)
            html = (f"<p><b>{agent}</b>: the vault appliance needs attention.</p><ul>{items}</ul>"
                    "<p>On the host: <code>alexandria doctor --vault &lt;vault&gt;</code>, and the "
                    "logs in <code>~/Library/Logs/alexandria/</code>.</p>")
            try:
                d.send_email(html, subject=f"[Alexandria] {agent} health alert",
                             to=to, frm=frm, from_name=agent)
                log(f"alerted {to}: {problems}")
            except Exception as e:
                log(f"alert email FAILED: {e}")
                return
        else:
            log(f"still unhealthy (already alerted): {problems}")
        _save_alert_state(True)
    else:
        if was_alerting and to:
            try:
                d.send_email(f"<p><b>{agent}</b>: the appliance is healthy again — all "
                             "heartbeats fresh.</p>",
                             subject=f"[Alexandria] {agent} recovered", to=to, frm=frm, from_name=agent)
                log(f"recovery notice sent to {to}")
            except Exception as e:
                log(f"recovery email failed: {e}")
        log("healthy")
        _save_alert_state(False)


if __name__ == "__main__":
    main()
