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

Two data sources:
  REST API  — stations, observations, forecast via WeatherFlow cloud
  UDP/local — real-time broadcast from your hub on port 50222

Requires TEMPEST_TOKEN env var (personal access token from weatherflow.com).
"""

import argparse
import json
import os
import socket
import struct
import sys
import time
import warnings
from datetime import datetime, timezone, timedelta
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_TOKEN = os.getenv("TEMPEST_TOKEN", "")
ENV_SERVER = os.getenv("TEMPEST_SERVER", DEFAULT_SERVER)

# === 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 == "--help" or arg == "-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."""

    def __init__(self, token: str = "", server: str = "", dry_run: bool = False):
        self.token = token or ENV_TOKEN
        self.server = (server or ENV_SERVER).rstrip("/")
        self.dry_run = dry_run

    def _get(self, path: str, params: Optional[Dict] = None) -> Any:
        """Generic GET with token auth."""
        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 generate a new one at weatherflow.com.")
        if resp.status_code == 403:
            die("Forbidden (403). Your token may 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."""
        data = self._get("/stations")
        return data if isinstance(data, list) else data.get("stations", [])

    def get_observations(self, device_id: int, days_back: int = 0, time_start: Optional[int] = None) -> Dict:
        """Get observations for a device. Use days_back=1 for last day, or time_start epoch."""
        params: Dict[str, Any] = {}
        if days_back > 0:
            params["day_offset"] = days_back
        elif time_start:
            params["time_start"] = time_start
        else:
            params["latest"] = "true"
        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 ===

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"),
    ("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"),
    ("wind_sample_interval", "seconds"),
]


def decode_obs(obs_array: List, type_str: str) -> Dict:
    """Decode a raw observation array into a dict with field names."""
    if type_str == "obs_st":
        fields = OBS_ST_FIELDS
    elif type_str == "obs_air":
        fields = OBS_AIR_FIELDS
    elif type_str == "obs_sky":
        fields = OBS_SKY_FIELDS
    else:
        return {f"field_{i}": v for i, v in enumerate(obs_array)}

    result = {}
    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."""
    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)"


# === CLI Commands ===

def cmd_stations(client: TempestClient, args: List[str]) -> None:
    """List stations and attached devices."""
    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(client: TempestClient, args: List[str]) -> None:
    """Get latest observations from your station."""
    parser = argparse.ArgumentParser(prog="tempest-cli current")
    parser.add_argument("--station-id", type=int, help="Station ID (optional if only one station)")
    parser.add_argument("--device-id", type=int, help="Device ID (default: first Tempest device)")
    parsed, _ = parser.parse_known_args(args)

    if client.dry_run:
        emit("[dry-run] Would query latest observations from your station.",
             {"dry_run": True, "command": "current",
              "station_id": parsed.station_id, "device_id": parsed.device_id})
        return

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

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

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

    # Pick device — skip the hub (HB), prefer ST (Tempest) or SKY/AIR
    if parsed.device_id:
        device = next((d for d in devices if d.get("device_id") == parsed.device_id), None)
    else:
        # Filter out the hub (device_type=HB), use first sensor
        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).")
        # Prefer Tempest (ST), then Sky, then Air
        for preferred in ("ST", "SKY", "AIR"):
            match = next((d for d in sensors if d.get("device_type") == preferred), None)
            if match:
                device = match
                break
        else:
            device = sensors[0]

    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(client: TempestClient, args: List[str]) -> None:
    """Get historical observations."""
    parser = argparse.ArgumentParser(prog="tempest-cli obs")
    parser.add_argument("--device-id", type=int, required=True, help="Device ID (required)")
    parser.add_argument("--days", type=int, default=1, help="Days back to fetch (default: 1)")
    parsed, _ = parser.parse_known_args(args)

    if client.dry_run:
        emit("[dry-run] Would fetch historical observations.",
             {"dry_run": True, "command": "obs",
              "device_id": parsed.device_id, "days": parsed.days})
        return

    obs_data = client.get_observations(parsed.device_id, days_back=parsed.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": parsed.device_id, "type": obs_type, "count": len(decoded), "observations": decoded})
    else:
        print(f"{len(decoded)} observations from the last {parsed.days} day(s) (type: {obs_type}):")
        print("")
        # Show latest 3
        for o in decoded[-3:]:
            print("---")
            print(format_current(o))
            print("")


def cmd_forecast(client: TempestClient, args: List[str]) -> None:
    """Get forecast — current conditions + daily + hourly."""
    parser = argparse.ArgumentParser(prog="tempest-cli forecast")
    parser.add_argument("--station-id", type=int, help="Station ID (optional if only one station)")
    parser.add_argument("--days", type=int, default=5, help="Days of daily forecast (default: 5)")
    parsed, _ = parser.parse_known_args(args)

    if client.dry_run:
        emit("[dry-run] Would fetch hyper-local forecast for your station.",
             {"dry_run": True, "command": "forecast",
              "station_id": parsed.station_id, "days": parsed.days})
        return

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

    if parsed.station_id:
        station = next((s for s in stations if s.get("station_id") == parsed.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

    # Current conditions
    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}")
        for key in ("air_temperature", "temperature"):
            if current.get(key) is not None:
                val = current[key]
                feels = current.get("feels_like")
                if feels is not None:
                    feels_f = feels * 9 / 5 + 32
                else:
                    feels_f = None
                print(f"Temperature: {val * 9 / 5 + 32:.0f}°F (feels like {feels_f:.0f}°F)" if feels_f is not None else f"Temperature: {val * 9 / 5 + 32:.0f}°F")
                break
        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']} mph {wd}")
        if current.get("conditions"):
            print(f"  [{current.get('conditions', '')}]")
        print()

    # Daily forecast
    daily = fc_data.get("daily", [])
    if daily:
        print(f"── {parsed.days}-Day Forecast ──")
        for day in daily[: parsed.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 = day.get("air_temp_high")
            lo = day.get("air_temp_low")
            hi_f = hi * 9 / 5 + 32 if hi is not None else None
            lo_f = lo * 9 / 5 + 32 if lo is not None else None
            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_f:.0f}" if hi_f is not None else "?"
            lo_str = f"{lo_f:.0f}" if lo_f is not None else "?"
            print(f"  {day_str}: {lo_str}–{hi_str}°F  {cond}{precip_str} {precip_type}".strip())
        print()

    # Hourly
    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_c = h.get("air_temperature")
            temp_str = f"{temp_c * 9 / 5 + 32:.0f}" if temp_c 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: List[str]) -> None:
    """Listen for local UDP broadcasts from the Tempest hub."""
    parser = argparse.ArgumentParser(prog="tempest-cli udp listen")
    parser.add_argument("--port", type=int, default=DEFAULT_UDP_PORT, help=f"UDP port (default: {DEFAULT_UDP_PORT})")
    parser.add_argument("--timeout", type=int, default=0, help="Listen for N seconds (0 = indefinite)")
    parser.add_argument("--show-all", action="store_true", help="Show raw message type even if unknown")
    parsed, _ = parser.parse_known_args(args)

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

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

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

            try:
                msg = json.loads(data.decode("utf-8", errors="replace"))
            except json.JSONDecodeError:
                if parsed.show_all:
                    log(f"[raw] from {addr[0]}: {data[:200]}")
                continue

            msg_type = msg.get("type", "unknown")
            sn = msg.get("serial_number", msg.get("hub_sn", "?"))

            if msg_type == "obs_st":
                for obs_arr in msg.get("obs", []):
                    decoded = decode_obs(obs_arr, "obs_st")
                    emit(f"\n── Tempest Observation from {sn} ──\n{format_current(decoded)}",
                         {"type": "obs_st", "serial_number": sn, "observation": decoded})
            elif msg_type == "obs_air":
                for obs_arr in msg.get("obs", []):
                    decoded = decode_obs(obs_arr, "obs_air")
                    emit(f"\n── Air Observation from {sn} ──\n{format_current(decoded)}",
                         {"type": "obs_air", "serial_number": sn, "observation": decoded})
            elif msg_type == "obs_sky":
                for obs_arr in msg.get("obs", []):
                    decoded = decode_obs(obs_arr, "obs_sky")
                    emit(f"\n── Sky Observation from {sn} ──\n{format_current(decoded)}",
                         {"type": "obs_sky", "serial_number": sn, "observation": decoded})
            elif msg_type == "rapid_wind":
                for ob in msg.get("ob", []):
                    ts = datetime.fromtimestamp(ob[0], tz=timezone.utc).isoformat() if len(ob) > 0 else "?"
                    speed = ob[1] if len(ob) > 1 else "?"
                    direction = ob[2] if len(ob) > 2 else "?"
                    card = wind_dir_to_cardinal(direction)
                    m_s_mph = f"{speed * 2.237:.1f} mph" if isinstance(speed, (int, float)) else "?"
                    emit(f"💨 Rapid Wind: {speed} m/s ({m_s_mph}) from {card} ({direction}°) [{ts}]",
                         {"type": "rapid_wind", "serial_number": sn,
                          "wind_speed_mps": speed, "wind_direction": direction,
                          "timestamp": ts})
            elif msg_type == "evt_strike":
                evt = msg.get("evt", [])
                ts = datetime.fromtimestamp(evt[0], tz=timezone.utc).isoformat() if len(evt) > 0 else "?"
                dist = evt[1] if len(evt) > 1 else "?"
                energy = evt[2] if len(evt) > 2 else "?"
                emit(f"⚡ Lightning Strike — distance: {dist} km, energy: {energy} [{ts}]",
                     {"type": "evt_strike", "serial_number": sn,
                      "distance_km": dist, "energy": energy, "timestamp": ts})
            elif msg_type == "evt_precip":
                evt = msg.get("evt", [])
                ts = datetime.fromtimestamp(evt[0], tz=timezone.utc).isoformat() if len(evt) > 0 else "?"
                emit(f"🌧️  Rain started [{ts}]",
                     {"type": "evt_precip", "serial_number": sn, "timestamp": ts})
            elif msg_type == "hub_status":
                if parsed.show_all:
                    emit(f"[hub_status] {sn} — freq: {msg.get('freq', '?')}",
                         {"type": "hub_status", "serial_number": sn, "status": msg})
            elif parsed.show_all:
                emit(f"[{msg_type}] from {sn}", {"type": msg_type, "serial_number": sn, "raw": msg})

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

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


# === Main ===

def main() -> None:
    global GLOBAL_FLAGS, QUIET
    GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv)
    if GLOBAL_FLAGS.get("quiet", False):
        QUIET = True
    if GLOBAL_FLAGS.get("json", False):
        warnings.simplefilter("ignore")

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

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

    # current
    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)")

    # obs
    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)")

    # forecast
    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)")

    # udp
    p_udp = sub.add_parser("udp", help="Local UDP broadcast commands")
    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")

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

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

    if args.command == "stations":
        if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
            die("TEMPEST_TOKEN not set. Get one at https://weatherflow.com")
        cmd_stations(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)), [])

    elif args.command == "current":
        if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
            die("TEMPEST_TOKEN not set.")
        cmd_current(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
                    filtered_argv[filtered_argv.index("current") + 1:])

    elif args.command == "obs":
        if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
            die("TEMPEST_TOKEN not set.")
        cmd_obs(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
                filtered_argv[filtered_argv.index("obs") + 1:])

    elif args.command == "forecast":
        if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
            die("TEMPEST_TOKEN not set.")
        cmd_forecast(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
                     filtered_argv[filtered_argv.index("forecast") + 1:])

    elif args.command == "udp":
        if args.udp_command == "listen":
            udp_listen(filtered_argv[filtered_argv.index("listen") + 1:])
        else:
            p_udp.print_help()
            sys.exit(1)


if __name__ == "__main__":
    main()
