#!/usr/bin/python3
"""ctg-alert-mail: deliver one host alert to THIS host's configured destination.

Every host-side alert sender in ctg-infrastructure (runner-host-cleanup,
pg-backup-monitor, deploy-watcher artifact rejections, the Rustinion canary)
calls this one tool. Where the alert goes is decided per host, in
/etc/ctg-alerts/route.conf, never by the sender (owner decisions 2026-10-17,
reworked 2026-11-04):

  * RedWeek hosts (rw-man-01..05)  -> email to ron.farrer@redweek.com (Resend)
  * personal / Errant hosts        -> THREE TIERS, see severity below.
  * the owner's Gmail is refused for `normal`; only `problem` and `emergency` may reach it.
  * ntfy is for EMERGENCIES only (owner, 2026-11-04: "ntfy should only be for
    emergencies"). There is no ntfy channel for normal/problem alerts.

Severity (--severity, default "normal"):
  normal     something the agent should look at / has fixed. Goes to the host route
             (channel = email -> email_to, channel = agent -> a notice file in the
             agent inbox that the Hermes host posts to Discord and acts on). The
             owner's Gmail is REFUSED.
  problem    degraded, not down, and the automatic fix (catch-up/retry/agent) did not
             work: ONE email to `problem_to` (route.conf key / CTG_ALERT_PROBLEM_TO,
             required, never falls back to email_to), plus one recovery email. May
             reach the owner's Gmail. NEVER ntfy.
  emergency  a host/service is down after agent triage, or data is at risk (e.g. no
             good backup for > 48 h despite retries). Email to `emergency_to`
             (required; may be the Gmail) AND, when route.conf has
             `emergency_ntfy_url`, a push to that ntfy topic. The only severity that
             ever posts to ntfy. Rustinion senders opt out with --no-ntfy /
             CTG_ALERT_NO_NTFY=1 (Rustinion never uses ntfy: email only).
Senders must hold problem/emergency back until the agent had its chance (see
packaging/ctg-ops ctg_escalate.py) and send at most one of each + one recovery per incident.

There is no built-in default destination: without a route.conf (or the
CTG_ALERT_* overrides below) the send fails with exit 1, so a forgotten config
shows up in the sender's log instead of mail landing somewhere unexpected.

route.conf (key = value, '#' comments):
    channel  = email | agent              (normal severity)
    email_to = someone@example.com        (channel = email; comma-separate several)
    agent_inbox = /var/lib/rustinion-canary/agent-inbox   (channel = agent)
    problem_to = owner@example.com        (severity problem; may be the Gmail)
    emergency_to = owner@example.com      (severity emergency; may be the Gmail)
    emergency_ntfy_url = https://ntfy.example/topic   (severity emergency only)
    (`channel = ntfy` is no longer accepted.)

Usage:
    echo "body text" | ctg-alert-mail --subject "rw-man-02 disk"
    ctg-alert-mail --subject S --body "text" [--severity normal|problem|emergency]
    ctg-alert-mail --check NAME ...   # agent inbox: stable check name (default: from the subject)
    ctg-alert-mail --dry-run --subject S < body   # print destination + alert, send nothing

Prints the Resend email id / inbox notice id (+ ntfy id) on success; exits 1 with
the reason on stderr on any failure, so callers keep their alert state
"unsent" and retry on their next run. An emergency whose email went out but whose
ntfy push failed still succeeds (a retry would duplicate the email); the failure is
printed on stderr.

Environment overrides (tests, one-off use):
    CTG_ALERT_CONF      route file           (default /etc/ctg-alerts/route.conf)
    CTG_ALERT_CHANNEL   email | agent        (overrides route.conf)
    CTG_ALERT_TO        email recipient(s)   (overrides route.conf)
    CTG_ALERT_PROBLEM_TO / CTG_ALERT_EMERGENCY_TO  recipient(s) per severity
    CTG_ALERT_NTFY_URL  emergency ntfy topic (overrides emergency_ntfy_url)
    CTG_ALERT_NO_NTFY=1 never push to ntfy (Rustinion senders)
    CTG_ALERT_AGENT_INBOX  agent inbox directory
    CTG_ALERT_FROM      sender               (default "CTG Alerts <alerts@errant.solutions>")
    CTG_ALERT_KEY_FILE  Resend API key file  (default /etc/ctg-alerts/resend.key;
                        a send-only key restricted to errant.solutions)
    CTG_ALERT_API       Resend endpoint      (default https://api.resend.com/emails)

Source: CanuteTheGreat/ctg-infrastructure alerting/ (install with alerting/install.sh,
or on ollivanders the packages ctg-alert-mail + ctg-ollivanders-ops).
"""

import argparse
import json
import os
import re
import socket
import sys
import time
import uuid
import urllib.error
import urllib.request

DEFAULT_CONF = "/etc/ctg-alerts/route.conf"
DEFAULT_FROM = "CTG Alerts <alerts@errant.solutions>"
DEFAULT_KEY_FILE = "/etc/ctg-alerts/resend.key"
DEFAULT_API = "https://api.resend.com/emails"
# Owner, 2026-10-17: "I don't want a bunch of alerts on my Gmail."
REFUSED_RECIPIENTS = {"canutethegreat@gmail.com"}
SEVERITIES = ("normal", "problem", "emergency")
DEFAULT_INBOX = "/var/lib/rustinion-canary/agent-inbox"
NTFY_PRIORITIES = {"min", "low", "default", "high", "urgent", "max", "1", "2", "3", "4", "5"}


class ConfigError(Exception):
    pass


def load_route(severity: str = "normal") -> dict:
    """The effective destination: route.conf overlaid with CTG_ALERT_* env.

    normal    -> channel email (email_to, Gmail refused) or agent (inbox notice)
    problem   -> email to problem_to only (Gmail allowed, never ntfy)
    emergency -> email to emergency_to (Gmail allowed) + ntfy when emergency_ntfy_url is set
                 and not suppressed (CTG_ALERT_NO_NTFY=1)
    """
    env = os.environ.get
    path = env("CTG_ALERT_CONF", DEFAULT_CONF)
    route: dict = {}
    try:
        with open(path) as fh:
            for n, raw in enumerate(fh, 1):
                line = raw.split("#", 1)[0].strip()
                if not line:
                    continue
                if "=" not in line:
                    raise ConfigError(f"{path}:{n}: expected key = value")
                k, v = (s.strip() for s in line.split("=", 1))
                route[k] = v
    except FileNotFoundError:
        pass
    for key, var in (("channel", "CTG_ALERT_CHANNEL"), ("email_to", "CTG_ALERT_TO"),
                     ("emergency_ntfy_url", "CTG_ALERT_NTFY_URL"), ("problem_to", "CTG_ALERT_PROBLEM_TO"),
                     ("emergency_to", "CTG_ALERT_EMERGENCY_TO"), ("agent_inbox", "CTG_ALERT_AGENT_INBOX")):
        if env(var):
            route[key] = env(var)
    if severity not in SEVERITIES:
        raise ConfigError(f"unknown severity {severity!r}")

    def recipients(key: str) -> list:
        to = [a.strip() for a in route.get(key, "").split(",") if a.strip()]
        if not to:
            raise ConfigError(f"severity {severity} but no {key} in {path} (CTG_ALERT_{key.upper()})")
        return to

    if severity == "problem":
        return {"channel": "email", "to": recipients("problem_to")}  # never ntfy
    if severity == "emergency":
        out = {"channel": "email", "to": recipients("emergency_to")}
        url = route.get("emergency_ntfy_url", "")
        if url and env("CTG_ALERT_NO_NTFY") != "1":
            if not url.startswith(("https://", "http://")):
                raise ConfigError(f"emergency_ntfy_url is not an http(s) URL in {path}")
            out["ntfy_url"] = url
        return out
    channel = route.get("channel", "")
    if channel == "email":
        to = recipients("email_to")
        refused = [a for a in to if a.lower() in REFUSED_RECIPIENTS]
        if refused:
            raise ConfigError(f"refusing to alert {', '.join(refused)} with severity normal "
                              "(owner: his Gmail only gets --severity problem|emergency)")
        return {"channel": "email", "to": to}
    if channel == "agent":
        return {"channel": "agent", "inbox": route.get("agent_inbox", DEFAULT_INBOX)}
    if channel == "ntfy":
        raise ConfigError(f"channel = ntfy in {path} is no longer supported: ntfy is emergency-only "
                          "(emergency_ntfy_url, --severity emergency)")
    raise ConfigError(f"no alert destination configured: set channel = email|agent in {path}")


def _post(req: urllib.request.Request, timeout: float, what: str) -> dict:
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read() or b"{}")
    except urllib.error.HTTPError as err:
        detail = err.read()[:300].decode(errors="replace")
        raise RuntimeError(f"{what} HTTP {err.code}: {detail}") from None


def send_email(to: list, subject: str, body: str, timeout: float) -> str:
    env = os.environ.get
    key_file = env("CTG_ALERT_KEY_FILE", DEFAULT_KEY_FILE)
    with open(key_file) as fh:
        key = fh.read().strip()
    if not key:
        raise RuntimeError(f"empty API key in {key_file}")
    payload = {"from": env("CTG_ALERT_FROM", DEFAULT_FROM), "to": to, "subject": subject,
               "text": f"{body}\n\n-- sent by ctg-alert-mail on {socket.gethostname()}"}
    req = urllib.request.Request(
        env("CTG_ALERT_API", DEFAULT_API), data=json.dumps(payload).encode(), method="POST",
        headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json",
                 "User-Agent": "ctg-alert-mail/3.0"})
    data = _post(req, timeout, "Resend")
    if not data.get("id"):
        raise RuntimeError(f"Resend returned no id: {str(data)[:300]}")
    return data["id"]


def send_ntfy(url: str, subject: str, body: str, priority: str, tags: str, timeout: float) -> str:
    # Plain-text body to the topic URL with Title/Priority/Tags headers. Header
    # values must be latin-1 for http.client, so other characters become '?'.
    headers = {"User-Agent": "ctg-alert-mail/3.0", "Title": subject.encode("latin-1", "replace").decode("latin-1"),
               "Priority": priority}
    if tags:
        headers["Tags"] = tags
    req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
    data = _post(req, timeout, "ntfy")
    return str(data.get("id") or "ok")


def slug(text: str) -> str:
    return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:60] or "alert"


def write_inbox(inbox: str, subject: str, body: str, check: str = "") -> str:
    """One agent-inbox notice (same JSON the Hermes gate reads: gate-rustinion-canary.py)."""
    check = check or slug(subject)
    now = time.time()
    nid = f"{int(now)}-{check}-agent_notice-{uuid.uuid4().hex[:6]}"
    doc = {"id": nid, "check": check, "kind": "agent_notice", "host": socket.gethostname().split(".")[0],
           "incident": f"{int(now)}", "title": subject, "since": now, "error": body[:900], "escalate_in": None,
           "simulation": False, "at": now}
    os.makedirs(inbox, exist_ok=True)
    os.chmod(inbox, 0o755)
    path = os.path.join(inbox, nid + ".json")
    tmp = f"{path}.{os.getpid()}.tmp"
    with open(tmp, "w") as fh:
        json.dump(doc, fh)
    os.chmod(tmp, 0o644)
    os.replace(tmp, path)
    return nid


def send(subject: str, body: str, priority: str = "", tags: str = "", timeout: float = 20,
         severity: str = "normal", check: str = "") -> str:
    """Deliver the alert to this host's route; returns the message id. Raises on any failure."""
    route = load_route(severity)
    if route["channel"] == "agent":
        return write_inbox(route["inbox"], subject, body, check)
    ids = send_email(route["to"], subject, body, timeout)
    if route.get("ntfy_url"):
        try:
            ids += "+ntfy:" + send_ntfy(route["ntfy_url"], subject, body, priority or "urgent", tags or "rotating_light", timeout)
        except Exception as err:  # noqa: BLE001 - the email is out; a retry would duplicate it
            print(f"ctg-alert-mail: emergency ntfy push failed (email was sent): {err}", file=sys.stderr)
    return ids


def describe(route: dict) -> str:
    if route["channel"] == "agent":
        return f"agent inbox {route['inbox']}"
    dest = f"email {', '.join(route['to'])}"
    return dest + (f" + ntfy {route['ntfy_url']}" if route.get("ntfy_url") else "")


def main() -> int:
    ap = argparse.ArgumentParser(description="Deliver a host alert to this host's configured destination.")
    ap.add_argument("--subject", required=True)
    ap.add_argument("--body", help="message text (default: read stdin)")
    ap.add_argument("--priority", default="", help="ntfy priority for an emergency push (default urgent)")
    ap.add_argument("--tags", default="", help="ntfy tags, comma-separated (emergency push only)")
    ap.add_argument("--check", default="", help="stable check name for the agent inbox (default: from the subject)")
    ap.add_argument("--severity", default="normal", choices=SEVERITIES,
                    help="normal = agent inbox / host route; problem = ONE email (never ntfy); "
                         "emergency = email + ntfy (the only ntfy path)")
    ap.add_argument("--no-ntfy", action="store_true", help="never push to ntfy (Rustinion senders)")
    ap.add_argument("--dry-run", action="store_true", help="print destination and alert, send nothing")
    args = ap.parse_args()
    if args.priority and args.priority not in NTFY_PRIORITIES:
        ap.error(f"--priority must be one of {sorted(NTFY_PRIORITIES)}")
    if args.no_ntfy:
        os.environ["CTG_ALERT_NO_NTFY"] = "1"
    body = args.body if args.body is not None else sys.stdin.read()
    if args.dry_run:
        try:
            dest = describe(load_route(args.severity))
        except ConfigError as err:
            dest = f"NOWHERE ({err})"
        print(f"[dry-run, not sent; severity {args.severity}; would go to {dest}] {args.subject}: {body}")
        return 0
    try:
        print(send(args.subject, body, args.priority, args.tags, severity=args.severity, check=args.check))
    except Exception as err:  # noqa: BLE001 - report every failure the same way
        print(f"ctg-alert-mail: send failed: {err}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
