#!/usr/bin/env python3
"""van-battery — battery / mains monitor for the campervan router (wayback).

While running **off mains** (AC offline, i.e. on battery) it sends escalating
Pushover alerts as the charge drops past each warn level, and at the shutdown
level it sends a final alert and powers the machine off cleanly.

Alerts are edge-triggered per discharge episode: each severity fires once, and
the whole sequence re-arms when mains power returns. Plug-out already below a
warn level fires a single alert for the current severity, not a burst.

Pushover credentials live in a separate 0600 secrets file (see credentials_path),
never in this repo. Missing/placeholder creds disable sending but NOT the
shutdown — running flat must always power down safely. Stdlib only.
"""

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

CONFIG_PATH = os.environ.get("VAN_BATTERY_CONFIG", "/etc/van-battery/config.json")
STATE_DIR = Path("/run/van-battery")
STATE_PATH = STATE_DIR / "state.json"
PSY = Path("/sys/class/power_supply")
HOST = socket.gethostname()

DEFAULTS = {
    "poll_interval": 30,                    # seconds between reads (battery moves slowly)
    "ac_path": "/sys/class/power_supply/AC0/online",
    "battery_path": "/sys/class/power_supply/BAT0",
    "warn_levels": [25, 20, 15],            # Pushover alert only
    "shutdown_level": 10,                   # Pushover alert + poweroff
    "shutdown_grace": 8,                    # seconds to let the alert flush before poweroff
    "credentials_path": "/etc/van-battery/pushover.json",
}

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_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 defaults", "warn")
    return cfg


def _by_type(kind):
    """Find a power_supply dir by its `type` (Mains / Battery) — fallback when the
    configured AC0/BAT0 name isn't present on this machine."""
    for d in sorted(PSY.glob("*")):
        try:
            if (d / "type").read_text().strip() == kind:
                return d
        except OSError:
            continue
    return None


def read_on_battery(cfg):
    """True if running on battery (mains absent), False if on mains, None if unknown."""
    p = Path(cfg["ac_path"])
    if not p.exists():
        d = _by_type("Mains")
        p = (d / "online") if d else None
    if not p or not p.exists():
        return None
    try:
        return p.read_text().strip() == "0"
    except OSError:
        return None


def read_capacity(cfg):
    """Battery charge percentage (int), or None."""
    d = Path(cfg["battery_path"])
    if not (d / "capacity").exists():
        d = _by_type("Battery") or d
    try:
        return int((d / "capacity").read_text().strip())
    except (OSError, ValueError):
        return None


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):
    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)
    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:
        log(f"pushover send failed: {e}", "warn")
        return False


def severity(cap, levels):
    """Most-severe (lowest) threshold the capacity has reached, or None if above all.
    levels: thresholds sorted ascending. cap=12, levels=[10,15,20,25] -> 15."""
    reached = [t for t in levels if cap <= t]
    return min(reached) if reached else None


def poweroff(cfg):
    log("shutdown level reached — powering off", "crit")
    time.sleep(cfg["shutdown_grace"])   # give the Pushover POST time to land first
    try:
        subprocess.run(["systemctl", "poweroff"], check=False)
    except Exception as e:
        log(f"poweroff failed: {e}", "crit")


def write_state(on_batt, cap, armed, warn_levels, shutdown_level):
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    payload = {
        "updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "on_battery": on_batt,
        "capacity": cap,
        "alerted_below": armed,   # lowest level alerted this discharge episode, or null
        "warn_levels": warn_levels,
        "shutdown_level": shutdown_level,
    }
    tmp = STATE_PATH.with_suffix(".tmp")
    tmp.write_text(json.dumps(payload))
    tmp.replace(STATE_PATH)


def main():
    cfg = load_config()
    levels = sorted(cfg["warn_levels"] + [cfg["shutdown_level"]])
    shutdown_level = cfg["shutdown_level"]
    last_alerted = None       # lowest threshold alerted in the current discharge episode
    shutdown_issued = False
    prev_on_batt = None
    log(f"van-battery up: poll {cfg['poll_interval']}s, warn {cfg['warn_levels']}, "
        f"shutdown {shutdown_level}%")

    while True:
        on_batt = read_on_battery(cfg)
        cap = read_capacity(cfg)

        capstr = f"{cap}%" if cap is not None else "unknown charge"

        # Mains <-> battery transition alerts. Skip the very first sample (prev is None)
        # so a restart while already on battery doesn't fire a spurious "on battery".
        if on_batt is not None and prev_on_batt is not None and on_batt != prev_on_batt:
            if on_batt:
                log(f"mains lost — running on battery at {capstr}", "warn")
                pushover(cfg, f"⚡ {HOST}: on battery",
                         f"Mains power lost — now running on battery ({capstr}). "
                         f"Low alerts at {cfg['warn_levels']}%, auto-shutdown at {shutdown_level}%.")
            else:
                log(f"mains restored at {capstr} — alerts re-armed")
                pushover(cfg, f"🔌 {HOST}: back on mains",
                         f"Mains power restored ({capstr}). Battery alert sequence re-armed.")

        if on_batt is False:
            # On mains: re-arm the whole sequence for the next discharge episode.
            last_alerted = None
            shutdown_issued = False
        elif on_batt is True and cap is not None:
            sev = severity(cap, levels)
            if sev is not None and (last_alerted is None or sev < last_alerted):
                last_alerted = sev
                is_shutdown = sev <= shutdown_level
                if is_shutdown:
                    pushover(cfg, f"⚠ {HOST}: battery {cap}% — shutting down",
                             f"On battery at {cap}% (≤{shutdown_level}%). Powering off now to "
                             f"protect the system.", priority=1)
                    if not shutdown_issued:
                        shutdown_issued = True
                        poweroff(cfg)
                else:
                    log(f"battery {cap}% on battery — alerting (level {sev})", "warn")
                    pushover(cfg, f"{HOST}: battery {cap}%",
                             f"Running on battery, charge down to {cap}% (alert at {sev}%). "
                             f"Shutdown at {shutdown_level}%.")

        prev_on_batt = on_batt
        try:
            write_state(on_batt, cap, last_alerted, cfg["warn_levels"], shutdown_level)
        except OSError as e:
            log(f"state write failed: {e}", "warn")
        time.sleep(cfg["poll_interval"])


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