#!/usr/bin/env python3
"""
sensor_dashboard.py — Termux phone sensor publisher for SaltyBot (Issue #574)

Reads phone IMU, GPS, and battery via termux-api and publishes JSON payloads
to the SaltyBot MQTT broker.  Designed to run on Android/Termux as a
persistent background service.

Topics
──────
  saltybot/phone/imu      — accelerometer + gyroscope  @ 5 Hz
  saltybot/phone/gps      — lat/lon/alt/accuracy        @ 1 Hz
  saltybot/phone/battery  — percentage/charging/temp    @ 1 Hz

JSON payloads
─────────────
  IMU:
    {"ts": 1710000000.000,
     "accel": {"x": 0.12, "y": -0.05, "z": 9.81},
     "gyro":  {"x": 0.01, "y": -0.00, "z": 0.00}}

  GPS:
    {"ts": 1710000000.000,
     "lat": 45.123, "lon": -73.456, "alt_m": 12.3,
     "accuracy_m": 3.5, "speed_ms": 0.0, "bearing_deg": 0.0,
     "provider": "gps"}

  Battery:
    {"ts": 1710000000.000,
     "pct": 87, "charging": true, "temp_c": 28.5,
     "health": "GOOD", "plugged": "AC"}

Usage
─────
  python3 phone/sensor_dashboard.py [OPTIONS]

  --broker HOST     MQTT broker IP/hostname  (default: 192.168.1.100)
  --port PORT       MQTT broker port         (default: 1883)
  --imu-hz FLOAT    IMU publish rate Hz      (default: 5.0)
  --gps-hz FLOAT    GPS publish rate Hz      (default: 1.0)
  --bat-hz FLOAT    Battery publish rate Hz  (default: 1.0)
  --qos INT         MQTT QoS level 0/1/2     (default: 0)
  --no-imu          Disable IMU publishing
  --no-gps          Disable GPS publishing
  --no-battery      Disable battery publishing
  --debug           Verbose logging

Dependencies (install in Termux)
─────────────────────────────────
  pkg install termux-api python
  pip install paho-mqtt
"""

import argparse
import json
import logging
import subprocess
import sys
import threading
import time
from typing import Optional

try:
    import paho.mqtt.client as mqtt
    MQTT_AVAILABLE = True
except ImportError:
    MQTT_AVAILABLE = False

# ── MQTT topic roots ──────────────────────────────────────────────────────────

TOPIC_IMU     = "saltybot/phone/imu"
TOPIC_GPS     = "saltybot/phone/gps"
TOPIC_BATTERY = "saltybot/phone/battery"

# ── termux-api wrappers ───────────────────────────────────────────────────────

def _run_termux(cmd: list[str], timeout: float = 5.0) -> Optional[dict]:
    """Run a termux-api command and parse its JSON output. Returns None on error."""
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
        )
        if result.returncode != 0 or not result.stdout.strip():
            logging.debug("termux cmd %s returned %d: %s",
                          cmd[0], result.returncode, result.stderr.strip())
            return None
        return json.loads(result.stdout)
    except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
        logging.debug("termux cmd %s error: %s", cmd[0], e)
        return None


def read_sensor_once(sensor_name: str) -> Optional[dict]:
    """
    Call termux-sensor for a single reading of @sensor_name.

    termux-sensor -s <name> -n 1 returns one sample then exits.
    """
    raw = _run_termux(["termux-sensor", "-s", sensor_name, "-n", "1"], timeout=4.0)
    if raw is None:
        return None
    # Response shape: {"<sensor_name>": {"values": [x, y, z], ...}}
    # Key may be the full sensor description string; iterate to find it.
    for key, val in raw.items():
        if isinstance(val, dict) and "values" in val:
            return val
    return None


def read_imu() -> Optional[dict]:
    """
    Read accelerometer and gyroscope in a single call each.
    Returns dict with 'accel' and 'gyro' sub-dicts, or None if both fail.
    """
    accel_data = read_sensor_once("accelerometer")
    gyro_data  = read_sensor_once("gyroscope")

    accel = None
    if accel_data and isinstance(accel_data.get("values"), list):
        v = accel_data["values"]
        if len(v) >= 3:
            accel = {"x": float(v[0]), "y": float(v[1]), "z": float(v[2])}

    gyro = None
    if gyro_data and isinstance(gyro_data.get("values"), list):
        v = gyro_data["values"]
        if len(v) >= 3:
            gyro = {"x": float(v[0]), "y": float(v[1]), "z": float(v[2])}

    if accel is None and gyro is None:
        return None

    return {
        "ts":    time.time(),
        "accel": accel or {"x": 0.0, "y": 0.0, "z": 0.0},
        "gyro":  gyro  or {"x": 0.0, "y": 0.0, "z": 0.0},
    }


def read_gps() -> Optional[dict]:
    """Read GPS fix via termux-location."""
    raw = _run_termux(["termux-location", "-p", "gps", "-r", "once"], timeout=10.0)
    if raw is None:
        # Fallback: try network provider if GPS cold
        raw = _run_termux(
            ["termux-location", "-p", "network", "-r", "once"], timeout=6.0
        )
    if raw is None:
        return None

    return {
        "ts":          time.time(),
        "lat":         float(raw.get("latitude",  0.0)),
        "lon":         float(raw.get("longitude", 0.0)),
        "alt_m":       float(raw.get("altitude",  0.0)),
        "accuracy_m":  float(raw.get("accuracy",  -1.0)),
        "speed_ms":    float(raw.get("speed",     0.0)),
        "bearing_deg": float(raw.get("bearing",   0.0)),
        "provider":    str(raw.get("provider", "unknown")),
    }


def read_battery() -> Optional[dict]:
    """Read battery status via termux-battery-status."""
    raw = _run_termux(["termux-battery-status"], timeout=4.0)
    if raw is None:
        return None

    return {
        "ts":      time.time(),
        "pct":     int(raw.get("percentage", -1)),
        "charging": raw.get("status", "DISCHARGING") not in ("DISCHARGING", "UNKNOWN"),
        "temp_c":  float(raw.get("temperature", 0.0)),
        "health":  str(raw.get("health", "UNKNOWN")),
        "plugged": str(raw.get("plugged", "UNPLUGGED")),
    }


# ── MQTT client with auto-reconnect ───────────────────────────────────────────

class MQTTPublisher:
    """
    Thin paho-mqtt wrapper with:
    - Automatic reconnect on disconnect (exponential back-off, max 60 s)
    - Thread-safe publish() — queues messages if offline
    - Connection status accessible via .connected property
    """

    _RECONNECT_BASE  = 2.0   # seconds
    _RECONNECT_MAX   = 60.0

    def __init__(self, broker: str, port: int, qos: int = 0):
        self._broker  = broker
        self._port    = port
        self._qos     = qos
        self._lock    = threading.Lock()
        self._connected = False
        self._reconnect_delay = self._RECONNECT_BASE

        self._client = mqtt.Client(client_id="saltybot-phone-sensor", clean_session=True)
        self._client.on_connect    = self._on_connect
        self._client.on_disconnect = self._on_disconnect
        self._client.reconnect_delay_set(
            min_delay=int(self._RECONNECT_BASE),
            max_delay=int(self._RECONNECT_MAX),
        )

        self._connect()

    def _connect(self) -> None:
        try:
            self._client.connect_async(self._broker, self._port, keepalive=60)
            self._client.loop_start()
            logging.info("MQTT connecting to %s:%d ...", self._broker, self._port)
        except Exception as e:
            logging.warning("MQTT initial connect error: %s", e)

    def _on_connect(self, client, userdata, flags, rc) -> None:
        if rc == 0:
            with self._lock:
                self._connected = True
                self._reconnect_delay = self._RECONNECT_BASE
            logging.info("MQTT connected to %s:%d", self._broker, self._port)
        else:
            logging.warning("MQTT connect failed rc=%d", rc)

    def _on_disconnect(self, client, userdata, rc) -> None:
        with self._lock:
            self._connected = False
        if rc != 0:
            logging.warning("MQTT unexpected disconnect (rc=%d) — paho will retry", rc)

    @property
    def connected(self) -> bool:
        with self._lock:
            return self._connected

    def publish(self, topic: str, payload: dict) -> bool:
        """Serialize @payload to JSON and publish to @topic. Returns True on success."""
        if not self.connected:
            logging.debug("MQTT offline — dropping %s", topic)
            return False
        try:
            msg = json.dumps(payload, separators=(",", ":"))
            info = self._client.publish(topic, msg, qos=self._qos, retain=False)
            return info.rc == mqtt.MQTT_ERR_SUCCESS
        except Exception as e:
            logging.warning("MQTT publish error on %s: %s", topic, e)
            return False

    def shutdown(self) -> None:
        self._client.loop_stop()
        self._client.disconnect()
        logging.info("MQTT disconnected.")


# ── Sensor polling threads ────────────────────────────────────────────────────

class SensorPoller(threading.Thread):
    """
    Polls a sensor function at a fixed rate and publishes to MQTT.
    Runs as a daemon thread so it exits cleanly when the main thread stops.
    """

    def __init__(self, name: str, read_fn, topic: str,
                 hz: float, publisher: MQTTPublisher):
        super().__init__(name=name, daemon=True)
        self._read_fn  = read_fn
        self._topic    = topic
        self._interval = 1.0 / hz
        self._pub      = publisher
        self._running  = False

        # Stats
        self.published  = 0
        self.errors     = 0

    def run(self) -> None:
        self._running = True
        logging.info("Poller '%s' started at %.1f Hz → %s",
                     self.name, 1.0 / self._interval, self._topic)
        while self._running:
            t0 = time.monotonic()
            try:
                data = self._read_fn()
                if data is not None:
                    if self._pub.publish(self._topic, data):
                        self.published += 1
                        logging.debug("%s: published %s", self.name,
                                      list(data.keys()))
                    else:
                        self.errors += 1
                else:
                    self.errors += 1
                    logging.debug("%s: read returned None", self.name)
            except Exception as e:
                self.errors += 1
                logging.warning("%s: read error: %s", self.name, e)

            elapsed = time.monotonic() - t0
            sleep_s = max(0.0, self._interval - elapsed)
            time.sleep(sleep_s)

    def stop(self) -> None:
        self._running = False


# ── Status logger ─────────────────────────────────────────────────────────────

def _status_logger(pollers: list[SensorPoller], publisher: MQTTPublisher,
                   stop_event: threading.Event) -> None:
    """Log per-poller stats every 30 s."""
    while not stop_event.wait(30.0):
        parts = [f"MQTT={'OK' if publisher.connected else 'DOWN'}"]
        for p in pollers:
            parts.append(f"{p.name}={p.published}ok/{p.errors}err")
        logging.info("Status — %s", "  ".join(parts))


# ── Entry point ───────────────────────────────────────────────────────────────

def main() -> None:
    parser = argparse.ArgumentParser(
        description="SaltyBot Termux sensor dashboard (Issue #574)"
    )
    parser.add_argument("--broker",     default="192.168.1.100",
                        help="MQTT broker IP/hostname (default: 192.168.1.100)")
    parser.add_argument("--port",       type=int, default=1883,
                        help="MQTT broker port (default: 1883)")
    parser.add_argument("--imu-hz",     type=float, default=5.0,
                        help="IMU publish rate Hz (default: 5.0)")
    parser.add_argument("--gps-hz",     type=float, default=1.0,
                        help="GPS publish rate Hz (default: 1.0)")
    parser.add_argument("--bat-hz",     type=float, default=1.0,
                        help="Battery publish rate Hz (default: 1.0)")
    parser.add_argument("--qos",        type=int, default=0, choices=[0, 1, 2],
                        help="MQTT QoS level (default: 0)")
    parser.add_argument("--no-imu",     action="store_true", help="Disable IMU")
    parser.add_argument("--no-gps",     action="store_true", help="Disable GPS")
    parser.add_argument("--no-battery", action="store_true", help="Disable battery")
    parser.add_argument("--debug",      action="store_true", help="Verbose logging")
    args = parser.parse_args()

    logging.basicConfig(
        level=logging.DEBUG if args.debug else logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
    )

    if not MQTT_AVAILABLE:
        logging.error("paho-mqtt not installed.  Run: pip install paho-mqtt")
        sys.exit(1)

    if args.no_imu and args.no_gps and args.no_battery:
        logging.error("All sensors disabled — nothing to do.")
        sys.exit(1)

    # Connect MQTT
    publisher = MQTTPublisher(args.broker, args.port, qos=args.qos)

    # Wait briefly for initial connection before starting pollers
    deadline = time.monotonic() + 10.0
    while not publisher.connected and time.monotonic() < deadline:
        time.sleep(0.2)
    if not publisher.connected:
        logging.warning("MQTT not yet connected — pollers will start anyway and retry.")

    # Build pollers for enabled sensors
    pollers: list[SensorPoller] = []
    if not args.no_imu:
        pollers.append(SensorPoller("imu",     read_imu,     TOPIC_IMU,     args.imu_hz, publisher))
    if not args.no_gps:
        pollers.append(SensorPoller("gps",     read_gps,     TOPIC_GPS,     args.gps_hz, publisher))
    if not args.no_battery:
        pollers.append(SensorPoller("battery", read_battery, TOPIC_BATTERY, args.bat_hz, publisher))

    for p in pollers:
        p.start()

    # Status log thread
    stop_evt = threading.Event()
    status_thread = threading.Thread(
        target=_status_logger, args=(pollers, publisher, stop_evt), daemon=True
    )
    status_thread.start()

    logging.info("Sensor dashboard running — Ctrl-C to stop.")
    try:
        while True:
            time.sleep(1.0)
    except KeyboardInterrupt:
        logging.info("Shutting down...")
    finally:
        stop_evt.set()
        for p in pollers:
            p.stop()
        publisher.shutdown()
        logging.info("Done.")


if __name__ == "__main__":
    main()
