#!/usr/bin/env python3
"""tempest — Hyper-local weather from your Tempest station.

Two data sources:
  REST API  — stations, observations, forecast via WeatherFlow cloud
              (primary, documented source; personal-use token)
  UDP/local — real-time JSON broadcast from your hub on port 50222
              (LAN-only backup; no auth, listen-only)

Message families over UDP are structurally different (obs_* nest rows under
"obs", rapid_wind carries one array under "ob", evt_* under "evt",
hub_status/device_status use named fields) — decode_message() dispatches on
"type" before any positional indexing.

Requires TEMPEST_TOKEN env var (personal access token created in the Tempest
web app: Settings -> Data Authorizations). Falls back to ~/.tempest.env.
"""

import argparse
import json
import os
import socket
import sys
import time
import warnings
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple

# === Suppress dependency warnings before imports ===
warnings.simplefilter("ignore")

import requests

# === Config ===
DEFAULT_SERVER = "https://swd.weatherflow.com/swd/rest"
DEFAULT_UDP_PORT = 50222
UDP_BROADCAST_ADDR = "0.0.0.0"
ENV_FILE = os.path.join("~", ".tempest.env")


def _load_env_file() -> str:
    """Fallback token source: ~/.tempest.env with a TEMPEST_TOKEN= line."""
    path = os.path.expanduser(ENV_FILE)
    try:
        with open(path, "r", encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if line.startswith("TEMPEST_TOKEN="):
                    value = line.split("=", 1)[1].strip()
                    return value.strip('"').strip("'")
    except OSError:
        pass
    return ""


def resolve_token() -> str:
    """TEMPEST_TOKEN env var wins; ~/.tempest.env is the fallback."""
    token = os.getenv("TEMPEST_TOKEN", "").strip()
    if token:
        return token
    return _load_env_file()


# === Logging ===
QUIET = False


def log(msg: str) -> None:
    """Log to stdout, suppressed in --json or --quiet mode."""
    if not QUIET and not GLOBAL_FLAGS.get("json", False):
        print(msg)


def warn(msg: str) -> None:
    print(f"Warning: {msg}", file=sys.stderr)


def die(msg: str, exit_code: int = 1) -> None:
    print(f"Error: {msg}", file=sys.stderr)
    sys.exit(exit_code)


def emit(human: str, data: Any) -> None:
    """Dual output — machine JSON or human text."""
    if GLOBAL_FLAGS.get("json", False):
        print(json.dumps(data, default=str))
    else:
        print(human)


# === Global flags (pre-parsed from argv) ===
GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False}


def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]:
    """Strip global flags from argv regardless of position."""
    GLOBAL_BOOLS = {"--json", "--dry-run", "--force", "--quiet", "--verbose"}
    flags: Dict[str, Any] = {}
    filtered: List[str] = [argv[0]]
    i = 1
    while i < len(argv):
        arg = argv[i]
        if arg in GLOBAL_BOOLS:
            flags[arg.lstrip("-").replace("-", "_")] = True
            i += 1
        elif arg in ("--help", "-h"):
            return flags, argv  # let argparse handle help
        elif arg == "--":
            filtered.extend(argv[i:])
            break
        else:
            filtered.append(arg)
            i += 1
    return flags, filtered


# === Tempest API Client ===
class TempestClient:
    """REST API client for WeatherFlow Tempest (swd.weatherflow.com)."""

    def __init__(self, token: str = "", server: str = "", dry_run: bool = False):
        self.token = token
        self.server = (server or os.getenv("TEMPEST_SERVER", DEFAULT_SERVER)).rstrip("/")
        self.dry_run = dry_run

    def _get(self, path: str, params: Optional[Dict] = None) -> Any:
        """Generic GET with token auth (query parameter per official docs)."""
        url = f"{self.server}{path}"
        if params is None:
            params = {}
        params["token"] = self.token

        if self.dry_run:
            return {"dry_run": True, "url": url, "params": params}

        try:
            resp = requests.get(url, params=params, timeout=30)
        except requests.ConnectionError as e:
            die(f"Cannot connect to {self.server}: {e}\n  Is the server reachable?")

        if resp.status_code == 401:
            die("Auth failed (401). Check your TEMPEST_TOKEN or create a new one "
                "in the Tempest web app (Settings -> Data Authorizations).")
        if resp.status_code == 403:
            die("Forbidden (403). Your token does not have access to this station/device.")
        if resp.status_code == 404:
            die(f"Not found (404) at {path}. Check station/device IDs.")
        if resp.status_code >= 400:
            try:
                detail = resp.json()
            except Exception:
                detail = resp.text[:200]
            die(f"API error ({resp.status_code}): {detail}")

        try:
            return resp.json()
        except ValueError:
            return {"raw": resp.text[:500]}

    # === Endpoints ===

    def get_stations(self) -> List[Dict]:
        """List all stations and their devices.

        The documented response is a StationSet wrapper ({stations: [...],
        status}); the legacy model used {locations: [...]} and some proxies
        have returned a bare list — unwrap all three shapes defensively.
        """
        data = self._get("/stations")
        if isinstance(data, list):
            return data
        if isinstance(data, dict):
            for key in ("stations", "locations"):
                if isinstance(data.get(key), list):
                    return data[key]
        return []

    def get_observations(self, device_id: int, days_back: int = 0,
                         time_start: Optional[int] = None,
                         time_end: Optional[int] = None) -> Dict:
        """Get observations for a device.

        day_offset=N fetches whole UTC day N (0 = today); a time_start/time_end
        epoch range overrides it (both, <= 5 days for minute resolution); with
        no range parameters the API returns only the latest observation.
        """
        params: Dict[str, Any] = {}
        if days_back > 0:
            params["day_offset"] = days_back
        elif time_start:
            params["time_start"] = time_start
        if time_end:
            params["time_end"] = time_end
        return self._get(f"/observations/device/{device_id}", params)

    def get_forecast(self, station_id: int) -> Dict:
        """Get better_forecast — current conditions + daily + hourly."""
        return self._get("/better_forecast", {"station_id": station_id})


# === Observation decoders (positional arrays; layout per `type`) ===
# REST record lengths: obs_st 22, obs_air 8, obs_sky 17. UDP truncates
# obs_st to 18 and obs_sky to 14 (Nearcast fields are REST-only) — decoders
# tolerate both lengths and emit None for missing trailing positions.

OBS_ST_FIELDS = [
    ("epoch", "seconds_utc"),
    ("wind_lull", "m/s"),
    ("wind_avg", "m/s"),
    ("wind_gust", "m/s"),
    ("wind_direction", "degrees"),
    ("wind_sample_interval", "seconds"),
    ("station_pressure", "MB"),
    ("air_temperature", "C"),
    ("relative_humidity", "%"),
    ("illuminance", "lux"),
    ("uv", "index"),
    ("solar_radiation", "W/m^2"),
    ("rain_accumulation", "mm"),
    ("precipitation_type", "0=none 1=rain 2=hail 3=rain+hail"),
    ("avg_strike_distance", "km"),
    ("strike_count", "count"),
    ("battery", "volts"),
    ("report_interval", "minutes"),
    ("local_day_rain_accumulation", "mm"),
    ("nc_rain_accumulation", "mm"),
    ("local_day_nc_rain_accumulation", "mm"),
    ("precip_analysis_type", "type"),
]

OBS_AIR_FIELDS = [
    ("epoch", "seconds_utc"),
    ("station_pressure", "MB"),
    ("air_temperature", "C"),
    ("relative_humidity", "%"),
    ("lightning_strike_count", "count"),
    ("lightning_avg_distance", "km"),
    ("battery", "volts"),
    ("report_interval", "minutes"),
]

OBS_SKY_FIELDS = [
    ("epoch", "seconds_utc"),
    ("illuminance", "lux"),
    ("uv", "index"),
    ("rain_accumulation", "mm"),
    ("wind_lull", "m/s"),
    ("wind_avg", "m/s"),
    ("wind_gust", "m/s"),
    ("wind_direction", "degrees"),
    ("battery", "volts"),
    ("report_interval", "minutes"),
    ("solar_radiation", "W/m^2"),
    ("local_day_rain_accumulation", "mm"),
    ("precipitation_type", "0=none 1=rain 2=hail 3=rain+hail"),
    ("wind_sample_interval", "seconds"),
    ("nc_rain_accumulation", "mm"),
    ("local_day_nc_rain_accumulation", "mm"),
    ("precip_analysis_type", "type"),
]

_OBS_LAYOUTS = {
    "obs_st": OBS_ST_FIELDS,
    "obs_air": OBS_AIR_FIELDS,
    "obs_sky": OBS_SKY_FIELDS,
}


def decode_obs(obs_array: List, type_str: str) -> Dict:
    """Decode one positional observation array into named fields.

    Values stay metric-native (m/s, mm, C, MB) — conversion is the caller's
    job. Rows may be shorter than the full layout (UDP truncates) or longer
    (REST extends obs_st); missing positions decode as None.
    """
    fields = _OBS_LAYOUTS.get(type_str)
    if fields is None:
        return {f"field_{i}": v for i, v in enumerate(obs_array)}

    result: Dict[str, Any] = {}
    for i, (name, unit) in enumerate(fields):
        val = obs_array[i] if i < len(obs_array) else None
        if name == "epoch" and val is not None:
            result["timestamp"] = datetime.fromtimestamp(val, tz=timezone.utc).isoformat()
        result[name] = val
        result[f"{name}_unit"] = unit if name != "epoch" else None
    return result


def wind_dir_to_cardinal(deg: Optional[float]) -> str:
    """Convert wind degrees to cardinal direction."""
    if deg is None:
        return "N/A"
    dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
            "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N"]
    idx = round(deg / 22.5)
    return dirs[idx % 16]


def format_current(obs: Dict) -> str:
    """Format current conditions for human display (converted from metric)."""
    lines = []
    if obs.get("air_temperature") is not None:
        temp_c = obs["air_temperature"]
        temp_f = temp_c * 9 / 5 + 32
        lines.append(f"🌡️  Temperature: {temp_c:.1f}°C / {temp_f:.1f}°F")

    if obs.get("relative_humidity") is not None:
        lines.append(f"💧 Humidity: {obs['relative_humidity']:.0f}%")

    if obs.get("station_pressure") is not None:
        press_mb = obs["station_pressure"]
        press_inhg = press_mb * 0.02953
        lines.append(f"🔵 Pressure: {press_mb:.1f} MB / {press_inhg:.2f} inHg")

    if obs.get("wind_avg") is not None:
        wind_mps = obs["wind_avg"]
        wind_mph = wind_mps * 2.237
        gust_mps = obs.get("wind_gust")
        gust_mph = gust_mps * 2.237 if gust_mps else None
        dir_deg = obs.get("wind_direction")
        card = wind_dir_to_cardinal(dir_deg)
        gust_str = f" (gust {gust_mph:.1f} mph)" if gust_mph else ""
        lines.append(f"💨 Wind: {wind_mps:.1f} m/s ({wind_mph:.1f} mph){gust_str} from {card} ({dir_deg:.0f}°)")

    if obs.get("illuminance") is not None and obs["illuminance"] > 0:
        lux = obs["illuminance"]
        lines.append(f"☀️  Illuminance: {lux:.0f} lux")
        if obs.get("solar_radiation") is not None and obs["solar_radiation"] > 0:
            lines.append(f"⚡ Solar Radiation: {obs['solar_radiation']:.0f} W/m²")
        if obs.get("uv") is not None:
            lines.append(f"🌞 UV Index: {obs['uv']:.1f}")

    if obs.get("rain_accumulation") is not None and obs["rain_accumulation"] > 0:
        rain_mm = obs["rain_accumulation"]
        rain_in = rain_mm / 25.4
        lines.append(f"🌧️  Rain (last interval): {rain_mm:.2f} mm / {rain_in:.3f} in")
    if obs.get("local_day_rain_accumulation") is not None and obs["local_day_rain_accumulation"] > 0:
        day_mm = obs["local_day_rain_accumulation"]
        day_in = day_mm / 25.4
        lines.append(f"🌧️  Rain (today): {day_mm:.2f} mm / {day_in:.3f} in")

    if obs.get("strike_count") is not None and obs["strike_count"] > 0:
        lines.append(f"⚡ Lightning strikes: {obs['strike_count']} (avg dist {obs.get('avg_strike_distance', '?')} km)")

    if obs.get("battery") is not None:
        lines.append(f"🔋 Battery: {obs['battery']:.2f}V")

    if obs.get("timestamp"):
        lines.append(f"🕐 Recorded: {obs['timestamp']}")

    return "\n".join(lines) if lines else "(no observations)"


# === UDP message-family dispatch ===
# Families differ structurally: obs_* nest report rows under "obs";
# rapid_wind carries ONE array under "ob"; evt_precip/evt_strike carry ONE
# array under "evt"; hub_status/device_status carry named fields only.
# Dispatch on "type" BEFORE any positional indexing.

def _iso(ts: Any) -> Any:
    """Epoch seconds -> ISO string; pass through anything else."""
    if isinstance(ts, (int, float)):
        return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
    return None


def _decode_event(msg: Dict, type_str: str) -> Tuple[str, Dict]:
    evt = msg.get("evt") or []
    ts = _iso(evt[0]) if len(evt) > 0 else None
    payload: Dict[str, Any] = {"type": type_str,
                               "serial_number": msg.get("serial_number", msg.get("hub_sn", "?")),
                               "timestamp": ts}
    if type_str == "evt_strike":
        dist = evt[1] if len(evt) > 1 else None
        energy = evt[2] if len(evt) > 2 else None
        payload.update({"distance_km": dist, "energy": energy})
        human = f"⚡ Lightning Strike — distance: {dist} km, energy: {energy} [{ts}]"
    else:  # evt_precip — rain started
        human = f"🌧️  Rain started [{ts}]"
    return human, payload


def _decode_obs_message(msg: Dict, type_str: str) -> List[Tuple[str, Dict]]:
    out: List[Tuple[str, Dict]] = []
    label = {"obs_st": "Tempest Observation", "obs_air": "Air Observation",
             "obs_sky": "Sky Observation"}.get(type_str, type_str)
    sn = msg.get("serial_number", msg.get("hub_sn", "?"))
    for obs_arr in msg.get("obs", []) or []:
        decoded = decode_obs(obs_arr, type_str)
        out.append((f"\n── {label} from {sn} ──\n{format_current(decoded)}",
                    {"type": type_str, "serial_number": sn, "observation": decoded}))
    return out


def _decode_rapid_wind(msg: Dict) -> Tuple[str, Dict]:
    # rapid_wind payload is a SINGLE 3-element array under "ob":
    # [epoch, wind speed m/s, wind direction degrees]. Do NOT iterate it
    # element-wise like an obs row list.
    ob = msg.get("ob") or []
    sn = msg.get("serial_number", msg.get("hub_sn", "?"))
    ts = _iso(ob[0]) if len(ob) > 0 else None
    speed = ob[1] if len(ob) > 1 else None
    direction = ob[2] if len(ob) > 2 else None
    if isinstance(speed, (int, float)) and isinstance(direction, (int, float)):
        card = wind_dir_to_cardinal(direction)
        mph = speed * 2.237
        human = f"💨 Rapid Wind: {speed} m/s ({mph:.1f} mph) from {card} ({direction}°) [{ts}]"
    else:
        human = f"💨 Rapid Wind: {speed} m/s from {direction}° [{ts}]"
    payload = {"type": "rapid_wind", "serial_number": sn,
               "wind_speed_mps": speed, "wind_direction": direction, "timestamp": ts}
    return human, payload


def _decode_hub_status(msg: Dict) -> Tuple[str, Dict]:
    # hub_status carries named fields (uptime, rssi, seq, reset_flags,
    # firmware_revision as a string, plus fs/radio_stats/mqtt_stats arrays).
    # There is no "freq" or "fs_version" field in the current protocol.
    sn = msg.get("serial_number", "?")
    payload = {"type": "hub_status", "serial_number": sn,
               "firmware_revision": msg.get("firmware_revision"),
               "uptime": msg.get("uptime"), "rssi": msg.get("rssi"),
               "seq": msg.get("seq"), "reset_flags": msg.get("reset_flags"),
               "radio_stats": msg.get("radio_stats")}
    human = (f"[hub_status] {sn} — uptime {payload['uptime']}s, "
             f"rssi {payload['rssi']}, seq {payload['seq']}, "
             f"reset_flags {payload['reset_flags']}")
    return human, payload


def _decode_device_status(msg: Dict) -> Tuple[str, Dict]:
    # device_status: named fields; sensor_status is a decimal bit-flag field
    # (0 = all sensors healthy).
    sn = msg.get("serial_number", "?")
    payload = {"type": "device_status", "serial_number": sn,
               "uptime": msg.get("uptime"), "voltage": msg.get("voltage"),
               "firmware_revision": msg.get("firmware_revision"),
               "rssi": msg.get("rssi"), "hub_rssi": msg.get("hub_rssi"),
               "sensor_status": msg.get("sensor_status")}
    human = (f"[device_status] {sn} — voltage {payload['voltage']}V, "
             f"rssi {payload['rssi']}, hub_rssi {payload['hub_rssi']}, "
             f"sensor_status {payload['sensor_status']}")
    return human, payload


def decode_message(msg: Dict, show_all: bool = False) -> List[Tuple[str, Dict]]:
    """Dispatch one decoded UDP datagram on its `type` and decode it.

    Returns a list of (human_text, json_payload) tuples (observations can
    carry multiple report rows). Unknown types and status families are
    suppressed unless show_all is set.
    """
    msg_type = msg.get("type", "unknown")
    if msg_type in ("obs_st", "obs_air", "obs_sky"):
        return _decode_obs_message(msg, msg_type)
    if msg_type == "rapid_wind":
        return [_decode_rapid_wind(msg)]
    if msg_type in ("evt_precip", "evt_strike"):
        return [_decode_event(msg, msg_type)]
    if msg_type == "hub_status":
        return [_decode_hub_status(msg)] if show_all else []
    if msg_type == "device_status":
        return [_decode_device_status(msg)] if show_all else []
    if show_all:
        sn = msg.get("serial_number", msg.get("hub_sn", "?"))
        return [(f"[{msg_type}] from {sn}", {"type": msg_type, "serial_number": sn, "raw": msg})]
    return []


def handle_datagram(data: bytes, show_all: bool = False) -> List[Tuple[str, Dict]]:
    """Decode one raw UDP datagram (bytes) — JSON parse, then family dispatch.

    Bad JSON yields [] (or a raw preview when show_all is set). No sockets
    are involved, so canned bytes can be fed directly in tests.
    """
    try:
        msg = json.loads(data.decode("utf-8", errors="replace"))
    except json.JSONDecodeError:
        if show_all:
            preview = data[:200].decode("utf-8", errors="replace")
            return [(f"[raw] {preview}", {"type": "unparseable", "raw": preview})]
        return []
    if not isinstance(msg, dict):
        return []
    return decode_message(msg, show_all)


# === CLI Commands ===

def _client_for() -> TempestClient:
    return TempestClient(token=resolve_token(), dry_run=GLOBAL_FLAGS.get("dry_run", False))


def _pick_device(devices: List[Dict]) -> Dict:
    """Auto-select a sensor device: skip hubs, prefer ST, then SKY/SK, then AIR/AR.

    device_type values per the OpenAPI schema are HB/AR/SK/ST; SKY and AIR
    are accepted as legacy aliases for SK and AR.
    """
    sensors = [d for d in devices if d.get("device_type") not in ("HB", "hub")]
    if not sensors:
        die("No sensor devices found on this station (only a hub). Hubs carry no observations.")
    for preferred in ("ST", "SKY", "SK", "AIR", "AR"):
        match = next((d for d in sensors if d.get("device_type") == preferred), None)
        if match:
            return match
    return sensors[0]


def cmd_stations(args: argparse.Namespace) -> None:  # noqa: ARG001 (uniform handler signature)
    """List stations and attached devices."""
    if GLOBAL_FLAGS.get("dry_run", False):
        emit("[dry-run] Would list stations and devices for your token.",
             {"dry_run": True, "command": "stations"})
        return

    client = _client_for()
    stations = client.get_stations()
    if not stations:
        emit("No stations found for this token.", {"stations": []})
        return

    output_human = []
    for s in stations:
        name = s.get("name", s.get("station_name", "Unnamed"))
        sid = s.get("station_id", "?")
        output_human.append(f"Station: {name} (id={sid})")
        for dev in s.get("devices", []):
            dev_id = dev.get("device_id", "?")
            dev_type = dev.get("device_type", "?")
            sn = dev.get("serial_number", "?")
            output_human.append(f"  ├─ Device: {dev_type} (id={dev_id}, sn={sn})")
            if dev.get("name"):
                output_human.append(f"  │  Name: {dev['name']}")

    emit("\n".join(output_human), {"stations": stations})


def cmd_current(args: argparse.Namespace) -> None:
    """Get latest observations from your station."""
    if GLOBAL_FLAGS.get("dry_run", False):
        emit("[dry-run] Would query latest observations from your station.",
             {"dry_run": True, "command": "current",
              "station_id": args.station_id, "device_id": args.device_id})
        return

    client = _client_for()
    stations = client.get_stations()
    if not stations:
        die("No stations found. Verify your TEMPEST_TOKEN.")

    if args.station_id:
        station = next((s for s in stations if s.get("station_id") == args.station_id), None)
        if not station:
            die(f"Station {args.station_id} not found.")
    else:
        station = stations[0]

    devices = station.get("devices", [])
    if not devices:
        die(f"Station '{station.get('name', '?')}' has no devices.")

    if args.device_id:
        device = next((d for d in devices if d.get("device_id") == args.device_id), None)
        if not device:
            die(f"Device {args.device_id} not found on this station.")
    else:
        device = _pick_device(devices)

    dev_id = device.get("device_id")
    log(f"Station: {station.get('name', '?')}  Device: {device.get('device_type', '?')} (id={dev_id})")

    obs_data = client.get_observations(dev_id)
    obs_list = obs_data.get("obs", [])
    obs_type = obs_data.get("type", "obs_st")

    if not obs_list:
        emit("No observations available yet.", {"observations": [], "type": obs_type})
        return

    latest = obs_list[-1]  # newest
    decoded = decode_obs(latest, obs_type)

    if GLOBAL_FLAGS.get("json", False):
        emit("", {"station": station.get("name", "?"), "device_id": dev_id,
                  "type": obs_type, "observation": decoded})
    else:
        print(format_current(decoded))


def cmd_obs(args: argparse.Namespace) -> None:
    """Get historical observations."""
    if GLOBAL_FLAGS.get("dry_run", False):
        emit("[dry-run] Would fetch historical observations.",
             {"dry_run": True, "command": "obs",
              "device_id": args.device_id, "days": args.days})
        return

    client = _client_for()
    obs_data = client.get_observations(args.device_id, days_back=args.days)
    obs_list = obs_data.get("obs", [])
    obs_type = obs_data.get("type", "obs_st")

    decoded = [decode_obs(o, obs_type) for o in obs_list]

    if GLOBAL_FLAGS.get("json", False):
        emit("", {"device_id": args.device_id, "type": obs_type,
                  "count": len(decoded), "observations": decoded})
    else:
        print(f"{len(decoded)} observations from the last {args.days} day(s) (type: {obs_type}):")
        print("")
        for o in decoded[-3:]:
            print("---")
            print(format_current(o))
            print("")


def cmd_forecast(args: argparse.Namespace) -> None:
    """Get forecast — current conditions + daily + hourly."""
    if GLOBAL_FLAGS.get("dry_run", False):
        emit("[dry-run] Would fetch hyper-local forecast for your station.",
             {"dry_run": True, "command": "forecast",
              "station_id": args.station_id, "days": args.days})
        return

    client = _client_for()
    stations = client.get_stations()
    if not stations:
        die("No stations found.")

    if args.station_id:
        station = next((s for s in stations if s.get("station_id") == args.station_id), None)
    else:
        station = stations[0]
    if not station:
        die("Station not found.")

    sid = station.get("station_id")
    name = station.get("name", "?")
    log(f"Fetching forecast for station '{name}' (id={sid})...\n")

    data = client.get_forecast(sid)
    fc_data = data.get("forecast", data)  # prefer nested "forecast" key, fall back to top-level

    if GLOBAL_FLAGS.get("json", False):
        emit("", {"station_id": sid, "station_name": name, "forecast": data})
        return

    # The response honors unit overrides and reports what it used in `units`.
    # Never assume Celsius/m/s: converting an already-imperial response
    # double-converts it into absurd values.
    units = data.get("units", {}) or {}
    temp_is_f = units.get("units_temp", "c") == "f"
    wind_unit = units.get("units_wind", "mps")

    def show_temp(value: Optional[float]) -> Optional[float]:
        if value is None:
            return None
        return value if temp_is_f else value * 9 / 5 + 32

    current = data.get("current_conditions", {})
    if current:
        print("── Current Conditions ──")
        icon = current.get("icon", "")
        cond = current.get("conditions", "")
        icon_str = f" ({icon})" if icon else ""
        print(f"Conditions: {cond}{icon_str}")
        temp = show_temp(current.get("air_temperature"))
        if temp is not None:
            feels = show_temp(current.get("feels_like"))
            if feels is not None:
                print(f"Temperature: {temp:.0f}°F (feels like {feels:.0f}°F)")
            else:
                print(f"Temperature: {temp:.0f}°F")
        if current.get("relative_humidity") is not None:
            print(f"Humidity: {current['relative_humidity']}%")
        if current.get("station_pressure") is not None:
            print(f"Pressure: {current['station_pressure']} MB")
        if current.get("wind_avg") is not None:
            wd = current.get("wind_direction_cardinal", "")
            print(f"Wind: {current['wind_avg']} {wind_unit} {wd}".rstrip())
        print()

    daily = fc_data.get("daily", [])
    if daily:
        print(f"── {args.days}-Day Forecast ──")
        for day in daily[: args.days]:
            day_start = day.get("day_start_local")
            if isinstance(day_start, (int, float)):
                day_str = datetime.fromtimestamp(day_start).strftime("%a %b %d")
            elif day_start:
                day_str = str(day_start).split("T")[0]
            else:
                day_str = "?"
            hi = show_temp(day.get("air_temp_high"))
            lo = show_temp(day.get("air_temp_low"))
            cond = day.get("conditions", "")
            precip = day.get("precip_probability")
            precip_str = f" {precip}%" if precip is not None else ""
            precip_type = day.get("precip_type", "")
            hi_str = f"{hi:.0f}" if hi is not None else "?"
            lo_str = f"{lo:.0f}" if lo is not None else "?"
            print(f"  {day_str}: {lo_str}–{hi_str}°F  {cond}{precip_str} {precip_type}".strip())
        print()

    hourly = fc_data.get("hourly", [])
    if hourly:
        print("── Next 12 Hours ──")
        for h in hourly[:12]:
            local_hour = h.get("local_hour")
            dt_str = f"{local_hour:02d}:00" if local_hour is not None else "?"
            temp = show_temp(h.get("air_temperature"))
            temp_str = f"{temp:.0f}" if temp is not None else "?"
            cond = h.get("conditions", "")
            precip = h.get("precip_probability")
            precip_str = f" {precip}%" if precip is not None else ""
            print(f"  {dt_str}: {temp_str}°F  {cond}{precip_str}")
        if len(hourly) > 12:
            print(f"  ... and {len(hourly) - 12} more hours")


# === UDP Commands ===

def udp_listen(args: argparse.Namespace) -> None:
    """Listen for local UDP broadcasts from the Tempest hub (port 50222).

    Listen-only: the hub broadcasts, nothing is ever sent back. Requires
    being on the same LAN as the hub — routed connectivity is not enough.
    """
    port = args.port
    timeout = args.timeout
    show_all = args.show_all

    if GLOBAL_FLAGS.get("dry_run", False):
        # Dry-run plans the listen instead of opening it: exits 0 without
        # creating or binding any socket, so it is safe anywhere (no hub
        # required, no LAN needed, no hanging on a broadcast port).
        emit("[dry-run] Would listen for Tempest UDP broadcasts on "
             f"{UDP_BROADCAST_ADDR}:{port} — binds no socket now.",
             {"dry_run": True, "command": "udp", "subcommand": "listen",
              "bind_address": UDP_BROADCAST_ADDR, "port": port,
              "timeout_seconds": timeout, "show_all": show_all})
        return

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((UDP_BROADCAST_ADDR, port))
    sock.settimeout(timeout if timeout > 0 else None)

    log(f"Listening for Tempest UDP broadcasts on port {port}...")
    if timeout > 0:
        log(f"Will stop after {timeout}s\n")

    try:
        start = time.time()
        while True:
            try:
                data, addr = sock.recvfrom(65535)
            except socket.timeout:
                log("Listen timeout reached.")
                break

            for human, payload in handle_datagram(data, show_all=show_all):
                emit(human, payload)

            if timeout > 0 and time.time() - start > timeout:
                break

    except KeyboardInterrupt:
        log("\nStopped.")
    finally:
        sock.close()


# === Parser ===

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="tempest",
        description="Hyper-local weather from your Tempest station.",
        epilog="Global flags can appear anywhere: tempest --json current --device-id X"
    )
    sub = parser.add_subparsers(dest="command", help="Available commands")

    sub.add_parser("stations", help="List stations and devices linked to your token")

    p_current = sub.add_parser("current", help="Latest observations from your station")
    p_current.add_argument("--station-id", type=int, help="Station ID (optional)")
    p_current.add_argument("--device-id", type=int, help="Device ID (optional)")

    p_obs = sub.add_parser("obs", help="Historical observations")
    p_obs.add_argument("--device-id", type=int, required=True, help="Device ID")
    p_obs.add_argument("--days", type=int, default=1, help="Days back (default: 1)")

    p_fcst = sub.add_parser("forecast", help="Hyper-local forecast (current + daily + hourly)")
    p_fcst.add_argument("--station-id", type=int, help="Station ID (optional)")
    p_fcst.add_argument("--days", type=int, default=5, help="Days of daily forecast (default: 5)")

    p_udp = sub.add_parser("udp", help="Local UDP broadcast commands (port 50222, listen-only)")
    udp_sub = p_udp.add_subparsers(dest="udp_command")
    p_listen = udp_sub.add_parser("listen", help="Listen for local UDP broadcasts from hub")
    p_listen.add_argument("--port", type=int, default=DEFAULT_UDP_PORT)
    p_listen.add_argument("--timeout", type=int, default=0, help="Listen N seconds (0=indefinite)")
    p_listen.add_argument("--show-all", action="store_true",
                          help="Show all message types incl. hub_status/device_status")

    return parser


_HANDLERS = {
    "stations": cmd_stations,
    "current": cmd_current,
    "obs": cmd_obs,
    "forecast": cmd_forecast,
    "udp": udp_listen,
}


# === Main ===

def main(argv: Optional[List[str]] = None) -> None:
    global QUIET
    base_argv = argv if argv is not None else sys.argv
    GLOBAL_FLAGS.clear()
    GLOBAL_FLAGS.update({"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False})
    flags, filtered_argv = _preparse_global_flags(base_argv)
    GLOBAL_FLAGS.update(flags)
    QUIET = bool(GLOBAL_FLAGS.get("quiet", False))
    if GLOBAL_FLAGS.get("json", False):
        warnings.simplefilter("ignore")

    parser = build_parser()
    args = parser.parse_args(filtered_argv[1:])

    if not args.command:
        parser.print_help()
        sys.exit(1)

    if args.command == "udp":
        if args.udp_command != "listen":
            parser.error("udp requires a subcommand: listen")
        # UDP listening needs no token — the hub broadcast is unauthenticated.
        udp_listen(args)
        return

    # REST commands need a token unless this is a dry-run plan.
    if not resolve_token() and not GLOBAL_FLAGS.get("dry_run", False):
        die("TEMPEST_TOKEN not set. Get one at https://weatherflow.com "
            "(Tempest web app -> Settings -> Data Authorizations) or export TEMPEST_TOKEN.")

    _HANDLERS[args.command](args)


if __name__ == "__main__":
    main()
