#!/usr/bin/env python3
# mm_meta:
#   name: WX (Weather Alerts)
#   emoji: ⛈️
#   language: Python
"""
WX (Weather Alerts) — MeshMonitor Script

Modes:
A) Timer Trigger / Timed Event (automatic alerts)
   - If MESSAGE env var is not set, assume TIMER mode.
   - Fetch NWS alerts for WX_LAT/WX_LON
   - Dedupes using WX_STATE_PATH
   - Emits NEW / UPDATED / CLEARED bulletins only when something changes

B) Auto Responder (on-demand)
   - !wx
   - !wx detail N
   - !wx help

Deployment note:
- Uses Python standard library only (no pip deps). Works in locked-down containers (PEP 668 safe).

v1.0.3 changes:
- Added MeshMonitor script metadata (mm_meta) for UI display + timer name autofill
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError


# =========================
# VERSION
# =========================

WX_VERSION = "v1.0.3"


# =========================
# KISS CONFIG (EDIT THESE)
# =========================

WX_LAT = 25.7617
WX_LON = -80.1918

# Allow severities. Set to empty set() to allow all.
WX_SEVERITIES_ALLOW = {"Extreme", "Severe", "Moderate"}

# Optional noise suppression. Set to empty set() to block nothing.
WX_EVENTS_BLOCK = {"Special Weather Statement"}

# NWS prefers a real UA with contact info
WX_USER_AGENT = "WX-MeshMonitor-Script (contact: you@example.com)"

# Timer behavior
WX_STATE_PATH = "/data/wx_state.json"
WX_TIMER_SILENT_NOCHANGE = True  # recommended

# If True: timer runs will NOT emit error messages on fetch failure (prevents mesh noise on transient outages)
TIMER_SILENT_ON_FETCH_ERROR = True

# LoRa-friendly chunking
MAX_LINE_LEN = 180

# Documentation only (does not control scheduling)
WX_TIMER_SCHEDULE_NOTE = "*/5 * * * *"

# Network timeout seconds
HTTP_TIMEOUT = 12

# Retry/backoff for transient failures (DNS "Try again", timeouts, intermittent connectivity)
HTTP_RETRIES = 3
HTTP_RETRY_SLEEP_SECONDS = 2


# =========================
# Data model
# =========================

@dataclass
class Alert:
    nid: str  # NWS feature id (URL string)

    event: str
    severity: str
    urgency: str
    certainty: str

    headline: str
    area_desc: str
    description: str
    instruction: str

    sent: str
    effective: str
    ends: str
    expires: str

    ugc: List[str]
    same: List[str]

    @staticmethod
    def from_feature(f: Dict[str, Any]) -> "Alert":
        nid = str(f.get("id") or "")
        p = (f.get("properties") or {})
        geo = (p.get("geocode") or {})

        ugc = geo.get("UGC") or []
        same = geo.get("SAME") or []

        return Alert(
            nid=nid,
            event=p.get("event") or "Unknown",
            severity=p.get("severity") or "Unknown",
            urgency=p.get("urgency") or "Unknown",
            certainty=p.get("certainty") or "Unknown",
            headline=p.get("headline") or "",
            area_desc=p.get("areaDesc") or "",
            description=p.get("description") or "",
            instruction=p.get("instruction") or "",
            sent=p.get("sent") or "",
            effective=p.get("effective") or "",
            ends=p.get("ends") or "",
            expires=p.get("expires") or "",
            ugc=[str(x) for x in ugc if x],
            same=[str(x) for x in same if x],
        )

    def ends_best(self) -> str:
        return self.ends or self.expires or self.sent or ""

    def fingerprint(self) -> str:
        """
        Stable-ish fingerprint for dedupe:
        - feature id + timestamps that commonly change on updates
        """
        base = "|".join([self.nid, self.sent, self.effective, self.ends, self.expires])
        return hashlib.sha256(base.encode("utf-8")).hexdigest()[:24]

    def summary_line(self) -> str:
        end = self.ends_best().replace("T", " ").replace("Z", "Z")
        return f"{self.event} [{self.severity}] (ends {end})"


# =========================
# MeshMonitor output helpers
# =========================

def emit_response(text: str) -> None:
    print(json.dumps({"response": text}, ensure_ascii=False))

def emit_responses(lines: List[str]) -> None:
    print(json.dumps({"responses": lines}, ensure_ascii=False))

def chunk_text(text: str, max_len: int = MAX_LINE_LEN) -> List[str]:
    out: List[str] = []
    cur = ""

    def flush() -> None:
        nonlocal cur
        if cur.strip():
            out.append(cur.rstrip())
        cur = ""

    for line in text.splitlines():
        line = line.rstrip()
        if not line:
            if len(cur) + 1 > max_len:
                flush()
            cur += "\n"
            continue

        if len(cur) + len(line) + 1 > max_len:
            flush()

        while len(line) > max_len:
            out.append(line[:max_len])
            line = line[max_len:]

        cur += line + "\n"

    flush()
    return [x for x in out if x.strip()]


# =========================
# NWS fetch + filters (stdlib only)
# =========================

def http_get_json(url: str) -> Dict[str, Any]:
    last_err: Optional[Exception] = None

    for attempt in range(1, HTTP_RETRIES + 1):
        req = Request(
            url,
            headers={
                "User-Agent": WX_USER_AGENT,
                "Accept": "application/geo+json",
            },
            method="GET",
        )

        try:
            with urlopen(req, timeout=HTTP_TIMEOUT) as resp:
                data = resp.read().decode("utf-8", errors="replace")
                return json.loads(data)

        except HTTPError as e:
            # HTTP errors are typically not transient; surface immediately.
            raise RuntimeError(f"HTTP {e.code} {e.reason}") from e

        except URLError as e:
            last_err = e
            # Common transient: EAI_AGAIN -> "[Errno -3] Try again"
            if attempt < HTTP_RETRIES:
                time.sleep(HTTP_RETRY_SLEEP_SECONDS)
                continue
            raise RuntimeError(f"URL error: {e.reason}") from e

        except Exception as e:
            last_err = e
            if attempt < HTTP_RETRIES:
                time.sleep(HTTP_RETRY_SLEEP_SECONDS)
                continue
            raise RuntimeError(str(e)) from e

    raise RuntimeError(str(last_err) if last_err else "Unknown error")


def fetch_alerts(lat: float, lon: float) -> List[Alert]:
    url = f"https://api.weather.gov/alerts/active?point={lat},{lon}"
    j = http_get_json(url)

    feats = j.get("features") or []
    alerts = [Alert.from_feature(f) for f in feats]

    out: List[Alert] = []
    for a in alerts:
        if WX_SEVERITIES_ALLOW and a.severity not in WX_SEVERITIES_ALLOW:
            continue
        if WX_EVENTS_BLOCK and a.event in WX_EVENTS_BLOCK:
            continue
        out.append(a)

    out.sort(key=lambda x: (x.severity, x.event, x.ends_best(), x.nid))
    return out


# =========================
# State (dedupe) for TIMER mode
# =========================

def utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

def load_state(path: str) -> Dict[str, Any]:
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f) or {}
    except Exception:
        return {}

def save_state(path: str, state: Dict[str, Any]) -> None:
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(state, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


# =========================
# Formatting
# =========================

def format_help() -> str:
    return "\n".join([
        f"WX HELP ({WX_VERSION})",
        "!wx              -> summary of active alerts",
        "!wx detail <N>   -> detailed bulletin for alert N",
        "!wx help         -> this help",
        "",
        f"Timer schedule note: {WX_TIMER_SCHEDULE_NOTE}",
    ]).strip()

def format_summary(alerts: List[Alert]) -> str:
    if not alerts:
        return "WX SUMMARY\nNo active alerts for this area."

    lines = ["WX SUMMARY"]
    for i, a in enumerate(alerts, start=1):
        lines.append(f"{i}) {a.summary_line()}")
    lines.append("")
    lines.append("Use: !wx detail <N>")
    return "\n".join(lines).strip()

def format_detail(alerts: List[Alert], n: int) -> str:
    if not alerts:
        return "WX\nNo active alerts for this area."

    if n < 1 or n > len(alerts):
        return f"WX\nOut of range. Choose 1..{len(alerts)}"

    a = alerts[n - 1]
    parts: List[str] = []

    parts.append(f"WX ALERT ({a.severity})")
    parts.append(a.event)

    if a.ugc:
        parts.append(f"UGC: {'-'.join(a.ugc)}")
    if a.same:
        parts.append(f"SAME: {','.join(a.same)}")

    end = a.ends_best()
    if end:
        parts.append(f"Ends: {end}")

    if a.area_desc:
        parts.append("")
        parts.append(a.area_desc)

    if a.instruction:
        parts.append(a.instruction.strip())
    elif a.headline:
        parts.append(a.headline.strip())
    elif a.description:
        parts.append(a.description.strip())

    return "\n".join(parts).strip()

def format_timer_bulletin(title: str, alerts: List[Alert]) -> str:
    lines: List[str] = [title]
    for a in alerts:
        lines.append(f"- {a.summary_line()}")
    return "\n".join(lines).strip()


# =========================
# Commands (Auto Responder mode)
# =========================

def parse_command(message: str) -> Tuple[str, Optional[int]]:
    msg = message.strip()

    if re.fullmatch(r"!wx", msg, flags=re.IGNORECASE):
        return ("summary", None)

    m = re.fullmatch(r"!wx\s+detail\s+([0-9]+)", msg, flags=re.IGNORECASE)
    if m:
        return ("detail", int(m.group(1)))

    if re.fullmatch(r"!wx\s+help", msg, flags=re.IGNORECASE):
        return ("help", None)

    return ("unknown", None)


# =========================
# TIMER mode
# =========================

def run_timer_mode(alerts: List[Alert]) -> None:
    state = load_state(WX_STATE_PATH)

    prev_by_id: Dict[str, str] = dict(state.get("active_by_id") or {})
    prev_ids = set(prev_by_id.keys())

    cur_by_id: Dict[str, str] = {a.nid: a.fingerprint() for a in alerts}
    cur_ids = set(cur_by_id.keys())

    new_ids = sorted(cur_ids - prev_ids)
    cleared_ids = sorted(prev_ids - cur_ids)

    updated_ids: List[str] = []
    for nid in sorted(cur_ids & prev_ids):
        if prev_by_id.get(nid) != cur_by_id.get(nid):
            updated_ids.append(nid)

    responses: List[str] = []

    if new_ids:
        new_alerts = [a for a in alerts if a.nid in set(new_ids)]
        responses.append(format_timer_bulletin("WX NEW ALERT(S)", new_alerts))

    if updated_ids:
        upd_alerts = [a for a in alerts if a.nid in set(updated_ids)]
        responses.append(format_timer_bulletin("WX UPDATED ALERT(S)", upd_alerts))

    if cleared_ids:
        cleared_lines = ["WX CLEARED ALERT(S)"]
        prev_meta: Dict[str, Any] = dict(state.get("meta_by_id") or {})
        for nid in cleared_ids:
            meta = prev_meta.get(nid) or {}
            ev = meta.get("event") or "Alert"
            sev = meta.get("severity") or ""
            cleared_lines.append(f"- {ev}{' [' + sev + ']' if sev else ''}")
        responses.append("\n".join(cleared_lines).strip())

    meta_by_id: Dict[str, Any] = {}
    for a in alerts:
        meta_by_id[a.nid] = {"event": a.event, "severity": a.severity}

    save_state(WX_STATE_PATH, {
        "last_run_utc": utc_now_iso(),
        "active_by_id": cur_by_id,
        "meta_by_id": meta_by_id,
    })

    if not responses:
        if WX_TIMER_SILENT_NOCHANGE:
            emit_response("")
            return
        emit_response("WX: no changes.")
        return

    final_msgs: List[str] = []
    for b in responses:
        final_msgs.extend(chunk_text(b, MAX_LINE_LEN))

    if len(final_msgs) == 1:
        emit_response(final_msgs[0])
    else:
        emit_responses(final_msgs)


# =========================
# Main
# =========================

def main() -> int:
    msg = (os.environ.get("MESSAGE") or "").strip()
    is_timer = not bool(msg)

    try:
        alerts = fetch_alerts(WX_LAT, WX_LON)
    except Exception as e:
        # Timer mode: optionally stay silent to avoid spamming the mesh on transient outages
        if is_timer and TIMER_SILENT_ON_FETCH_ERROR:
            emit_response("")
            return 0
        # On-demand: show error to requesting user
        emit_response(f"WX error: failed to fetch alerts ({e})")
        return 0

    # TIMER mode: no inbound message context
    if is_timer:
        run_timer_mode(alerts)
        return 0

    cmd, n = parse_command(msg)

    if cmd == "help":
        emit_response(format_help())
        return 0

    if cmd == "unknown":
        emit_response("WX: Unknown command. Try: !wx help")
        return 0

    if cmd == "summary":
        emit_response(format_summary(alerts))
        return 0

    if cmd == "detail" and n is not None:
        detail = format_detail(alerts, n)
        chunks = chunk_text(detail, MAX_LINE_LEN)
        if len(chunks) == 1:
            emit_response(chunks[0])
        else:
            emit_responses(chunks)
        return 0

    emit_response("WX: Unknown state. Try: !wx help")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
