#!/usr/bin/env python3
"""van-nvme-watch — pages when the NVMe root hits an I/O-timeout/reset event.

This exact kernel signature ("nvme nvmeN: I/O tag ... timeout, reset
controller") crashed and corrupted the NVMe root on 2026-08-02, then recurred
2026-08-04 and self-healed. Root cause is still unconfirmed (suspected
USB/PCIe host-bandwidth contention during heavy interface churn), so this
just watches for it recurring rather than trying to prevent it.

Tails `journalctl -kf` (event-driven, not polled) rather than sysfs like
van-thermal, since there's no sensor to sample — only a log line to catch.
On a match it watches a short grace window for either a clean controller
re-init (self-healed) or a follow-up ext4 error / a second timeout (escalating)
before paging, and includes a live SMART + superblock snapshot in the message
so the phone alert already answers "is the filesystem actually at risk".

Publishes /run/van-nvme-watch/state.json (last event, same convention as
van-failover/van-thermal). Pushover credentials shared with van-battery/
van-thermal (/etc/van-battery/pushover.json, 0600). Stdlib only.
"""

import json
import os
import queue
import re
import socket
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

CONFIG_PATH = os.environ.get("VAN_NVME_WATCH_CONFIG", "/etc/van-nvme-watch/config.json")
STATE_DIR = Path("/run/van-nvme-watch")
STATE_PATH = STATE_DIR / "state.json"
HOST = socket.gethostname()

DEFAULTS = {
    "nvme_device": "nvme0",            # controller name as it appears in dmesg / smartctl target
    "fs_device": "/dev/nvme0n1p2",     # for the superblock snapshot
    "grace_period": 20,                # seconds to watch for escalation after a timeout line
    "cooldown": 300,                   # minimum seconds between Pushover sends
    "credentials_path": "/etc/van-battery/pushover.json",
}

# The exact crash/recurrence signature, kept nvme-scoped and loose enough to
# catch variant opcodes/tags/queue ids without matching unrelated nvme lines.
TIMEOUT_RE = re.compile(r"nvme (nvme\d+): .*\b(?:timeout|reset controller)\b", re.IGNORECASE)
# Signs the event is more than a clean self-heal: an actual filesystem error,
# or forced read-only remount, following the reset.
ESCALATION_RE = re.compile(
    r"EXT4-fs error|EXT4-fs.*remount.*read-only|Remounting filesystem read-only|"
    r"Buffer I/O error|I/O error, dev nvme",
    re.IGNORECASE,
)

PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}


def log(msg, level="info"):
    pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
    print(pri + msg, flush=True)


def load_creds(cfg):
    try:
        c = json.loads(Path(cfg["credentials_path"]).read_text())
        token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
        if token in PLACEHOLDERS or user in PLACEHOLDERS:
            return None
        return token, user
    except FileNotFoundError:
        return None
    except Exception as e:
        log(f"credentials {cfg['credentials_path']} unreadable ({e})", "warn")
        return None


def pushover(cfg, title, message, priority=0, attempts=3, retry_delay=15):
    creds = load_creds(cfg)
    if not creds:
        log(f"pushover skipped (no credentials): {title} — {message}", "warn")
        return False
    token, user = creds
    data = urllib.parse.urlencode({
        "token": token, "user": user, "title": title,
        "message": message, "priority": priority,
    }).encode()
    req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
    for attempt in range(1, attempts + 1):
        try:
            with urllib.request.urlopen(req, timeout=10) as resp:
                ok = resp.status == 200
            if not ok:
                log(f"pushover HTTP {resp.status}", "warn")
            return ok
        except Exception as e:
            last = attempt == attempts
            log(f"pushover send failed ({attempt}/{attempts}): {e}", "warn")
            if not last:
                time.sleep(retry_delay)
    return False


def load_config():
    cfg = dict(DEFAULTS)
    try:
        with open(CONFIG_PATH) as f:
            cfg.update(json.load(f))
    except FileNotFoundError:
        log(f"config {CONFIG_PATH} not found, using built-in defaults")
    except Exception as e:
        log(f"config {CONFIG_PATH} unreadable ({e}), using built-in defaults", "warn")
    return cfg


def snapshot_health(cfg):
    """Best-effort SMART + superblock snapshot for the alert body — answers
    "did this one actually hurt the filesystem" without needing to SSH in."""
    info = {}
    try:
        out = subprocess.run(["smartctl", "-a", f"/dev/{cfg['nvme_device']}"],
                              capture_output=True, text=True, timeout=15).stdout
        for key, pattern in (
            ("smart_health", r"SMART overall-health self-assessment test result:\s*(\S+)"),
            ("smart_critical_warning", r"Critical Warning:\s*(\S+)"),
            ("smart_media_errors", r"Media and Data Integrity Errors:\s*(\d+)"),
        ):
            m = re.search(pattern, out)
            info[key] = m.group(1) if m else "?"
    except Exception as e:
        info["smart_error"] = str(e)
    try:
        out = subprocess.run(["tune2fs", "-l", cfg["fs_device"]],
                              capture_output=True, text=True, timeout=15).stdout
        m = re.search(r"Filesystem state:\s*(\S+)", out)
        info["fs_state"] = m.group(1) if m else "?"
    except Exception as e:
        info["fs_state_error"] = str(e)
    return info


def write_state(payload):
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    tmp = STATE_PATH.with_suffix(".tmp")
    tmp.write_text(json.dumps(payload))
    tmp.replace(STATE_PATH)


def journal_reader(q):
    """Runs forever in a background thread, pushing new kernel log lines onto
    q. journalctl itself can exit (journald restart, etc.) — respawn it."""
    while True:
        try:
            proc = subprocess.Popen(
                ["journalctl", "-kf", "-n", "0", "-o", "cat"],
                stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                text=True, bufsize=1)
            for line in proc.stdout:
                q.put(line.rstrip("\n"))
            proc.wait()
            log(f"journalctl exited (code {proc.returncode}), restarting in 5s", "warn")
        except Exception as e:
            log(f"journalctl reader error: {e}, restarting in 5s", "warn")
        time.sleep(5)


def finalize(cfg, pending, escalated, reason, alert_state):
    """Wrap up a pending event: snapshot health, log, publish state, and
    Pushover (subject to cooldown). alert_state is a {"last_alert": monotonic}
    box shared across calls so the cooldown persists across events."""
    health = snapshot_health(cfg)
    outcome = "ESCALATED" if escalated else "self-healed"
    icon = "🚨" if escalated else "⚠️"
    priority = 1 if escalated else 0

    lines = [f"first: {pending['line']}"]
    if pending.get("count", 1) > 1:
        lines.append(f"repeated {pending['count']}x within the grace window")
    if pending.get("escalation_line"):
        lines.append(f"then: {pending['escalation_line']}")
    lines.append(f"fs state: {health.get('fs_state', '?')} | "
                 f"SMART: {health.get('smart_health', '?')}, "
                 f"critical_warning={health.get('smart_critical_warning', '?')}, "
                 f"media_errors={health.get('smart_media_errors', '?')}")
    message = "\n".join(lines)

    log(f"nvme event {outcome} ({reason}): {message}", "crit" if escalated else "warn")

    write_state({
        "updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "device": pending["device"],
        "count": pending.get("count", 1),
        "escalated": escalated,
        "reason": reason,
        "first_line": pending["line"],
        "escalation_line": pending.get("escalation_line"),
        "health": health,
    })

    if time.monotonic() - alert_state["last_alert"] >= cfg["cooldown"]:
        pushover(cfg, f"{icon} {HOST}: NVMe timeout — {outcome}", message, priority=priority)
        alert_state["last_alert"] = time.monotonic()
    else:
        log("pushover suppressed (cooldown)", "warn")


def main():
    cfg = load_config()
    log(f"van-nvme-watch up: tailing journalctl -k for nvme timeout/reset events "
        f"(grace period {cfg['grace_period']}s, cooldown {cfg['cooldown']}s)")

    q = queue.Queue()
    threading.Thread(target=journal_reader, args=(q,), daemon=True).start()

    pending = None
    alert_state = {"last_alert": 0.0}

    while True:
        if pending is not None:
            remaining = pending["deadline"] - time.monotonic()
            if remaining <= 0:
                finalize(cfg, pending, False, "grace period elapsed", alert_state)
                pending = None
                continue
            try:
                line = q.get(timeout=remaining)
            except queue.Empty:
                continue
        else:
            line = q.get()

        if pending is None:
            m = TIMEOUT_RE.search(line)
            if m:
                pending = {"device": m.group(1), "line": line, "count": 1,
                           "deadline": time.monotonic() + cfg["grace_period"]}
                log(f"nvme timeout detected: {line}", "warn")
            continue

        if TIMEOUT_RE.search(line):
            pending["count"] += 1
            if pending["count"] >= 2:
                finalize(cfg, pending, True, "repeated timeouts", alert_state)
                pending = None
            continue

        if ESCALATION_RE.search(line):
            pending["escalation_line"] = line
            finalize(cfg, pending, True, "filesystem error followed", alert_state)
            pending = None


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(0)
