"""Nav2 goal-status monitor — arrival/failure feedback for Gemini voice nav.

web_nav3's ``goto()`` is fire-and-forget: it publishes a goal and returns at
once, with no completion event. To let Gemini tell the user the truth ("we've
arrived" / "I couldn't get there") instead of guessing, we watch the Nav2
action status over rosbridge and, on a terminal status, push a
``[NAV ARRIVED]`` / ``[NAV FAILED]`` note to the Gemini child (via
``live_sub.send_state``) and release the nav arbiter so the legs free up.

Design
------
* One background daemon thread runs an asyncio loop holding a persistent
  rosbridge websocket subscription to ``/navigate_to_pose/_action/status``
  (``action_msgs/msg/GoalStatusArray``).
* ``arm_goal(place)`` marks a pending destination. A goal that is ACCEPTED/
  EXECUTING and not already-terminal is latched as "ours"; its terminal status
  fires feedback. A CANCELED of the latched goal while another goal is active
  is treated as a preemption (re-latch, don't fire). Re-arming supersedes.
* A watchdog fails the goal after ``SANAD_NAV_GOAL_TIMEOUT_S`` so the arbiter
  is ALWAYS released even if rosbridge/websockets is unavailable.
* ``request_cancel()`` sends a real rosbridge action CancelGoal (cancel-all) so
  "stop" actually stops Nav2 — not just an arbiter release.

Everything is best-effort: the drive already succeeded by the time we arm, so
any monitor failure simply means no spoken feedback — never a crash.
"""
from __future__ import annotations

import asyncio
import base64
import json
import os
import threading
import time
from typing import Any, List, Optional

from Project.Sanad.core.logger import get_logger

log = get_logger("goal_monitor")

# Capture the arbiter at import so releasing the legs never depends on a lazy
# import succeeding inside the fire path (a missed release locks the legs).
try:
    from Project.Sanad.dashboard.routes import _arbiter as _ARBITER
except Exception:  # noqa: BLE001
    _ARBITER = None

_ROSBRIDGE_URL = (
    os.environ.get("SANAD_ROSBRIDGE_URL")
    or os.environ.get("ROSBRIDGE_URL")
    or "ws://127.0.0.1:9090"
)
_STATUS_TOPIC = os.environ.get(
    "SANAD_NAV_STATUS_TOPIC", "/navigate_to_pose/_action/status"
)
_STATUS_TYPE = "action_msgs/msg/GoalStatusArray"
_CANCEL_SERVICE = os.environ.get(
    "SANAD_NAV_CANCEL_SERVICE", "/navigate_to_pose/_action/cancel_goal"
)
_GOAL_TIMEOUT_S = float(os.environ.get("SANAD_NAV_GOAL_TIMEOUT_S", "240"))

# action_msgs/msg/GoalStatus.status
_ACCEPTED, _EXECUTING = 1, 2
_SUCCEEDED, _CANCELED, _ABORTED = 4, 5, 6
_TERMINAL = {_SUCCEEDED, _CANCELED, _ABORTED}


def _uuid_of(status: dict) -> Any:
    """Canonical, encoding-independent key for a goal id.

    rosbridge may serialize uint8[16] as a base64 string OR an int list
    depending on its png/cbor config; normalize both to a tuple of ints so the
    ACCEPTED frame and the terminal frame compare equal even if the bridge
    switches representation mid-session."""
    gid = ((status.get("goal_info") or {}).get("goal_id") or {})
    u = gid.get("uuid")
    if isinstance(u, str):
        try:
            return tuple(base64.b64decode(u))
        except Exception:
            return u
    if isinstance(u, list):
        try:
            return tuple(int(x) for x in u)
        except Exception:
            return tuple(u)
    return None


class _GoalMonitor:
    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._active = False
        self._place: Optional[str] = None
        self._armed_at = 0.0
        self._latched: Any = None
        self._ignore: set = set()          # uuids seen terminal — never latch
        self._thread: Optional[threading.Thread] = None

    # ── public ───────────────────────────────────────────────
    def arm(self, place: str) -> None:
        spawn = False
        with self._lock:
            self._active = True
            self._place = place
            self._armed_at = time.time()
            self._latched = None
            self._ignore = set()
            if self._thread is None:
                self._thread = threading.Thread(
                    target=self._run, daemon=True, name="nav-goal-monitor")
                t = self._thread
                spawn = True
        if spawn:
            t.start()
        log.info("armed goal monitor for '%s'", place)

    def disarm(self) -> None:
        with self._lock:
            self._active = False
            self._latched = None
            self._place = None

    # ── thread / loop ────────────────────────────────────────
    def _run(self) -> None:
        # Loop so that a new arm() arriving exactly as a session ends keeps the
        # monitor alive. The decision to exit is made under the lock together
        # with clearing _thread, so arm()'s "spawn only if _thread is None" can
        # never strand an active goal with no live thread.
        while True:
            try:
                asyncio.run(self._serve())
            except Exception as exc:  # noqa: BLE001
                log.warning("goal monitor loop ended: %s", exc)
            with self._lock:
                if not self._active:
                    self._thread = None
                    return
            # still active → a fresh goal arrived; serve again

    async def _serve(self) -> None:
        try:
            import websockets  # local import — dashboard env only
        except Exception as exc:  # noqa: BLE001
            # No ws client → honor the timeout so the arbiter is still released.
            log.warning("websockets unavailable — nav feedback via timeout only: %s", exc)
            while True:
                await asyncio.sleep(min(5.0, _GOAL_TIMEOUT_S))
                if self._check_timeout():
                    return
                with self._lock:
                    if not self._active:
                        return
        while True:
            try:
                await self._listen(websockets)
            except Exception as exc:  # noqa: BLE001
                log.debug("rosbridge listen error: %s", exc)
            if self._check_timeout():
                return
            with self._lock:
                if not self._active:
                    return
            await asyncio.sleep(3.0)

    async def _listen(self, websockets: Any) -> None:
        async with websockets.connect(
            _ROSBRIDGE_URL, ping_interval=20, ping_timeout=20
        ) as ws:
            await ws.send(json.dumps({
                "op": "subscribe", "topic": _STATUS_TOPIC,
                "type": _STATUS_TYPE, "throttle_rate": 0, "queue_length": 1,
            }))
            log.info("goal monitor subscribed %s", _STATUS_TOPIC)
            while True:
                try:
                    raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
                except asyncio.TimeoutError:
                    if self._check_timeout():
                        return
                    with self._lock:
                        if not self._active:
                            return
                    continue
                try:
                    data = json.loads(raw)
                except Exception:
                    continue
                if data.get("op") != "publish":
                    continue
                msg = data.get("msg") or {}
                self._on_status(msg.get("status_list") or [])
                if self._check_timeout():
                    return
                with self._lock:
                    if not self._active:
                        return

    # ── status handling ──────────────────────────────────────
    def _on_status(self, status_list: List[dict]) -> None:
        fire: Optional[tuple] = None
        with self._lock:
            if not self._active:
                return
            states = {}                       # uuid -> status (this frame)
            for s in status_list:
                u = _uuid_of(s)
                st = s.get("status")
                if u is None:
                    continue
                states[u] = st
                if st in _TERMINAL:
                    self._ignore.add(u)       # prior/other goals — never ours
            # Latch a genuinely-active, non-ignored goal as ours.
            if self._latched is None:
                cand = [u for u, st in states.items()
                        if st in (_ACCEPTED, _EXECUTING) and u not in self._ignore]
                if cand:
                    self._latched = cand[-1]  # newest Nav2 entry
            # Terminal for the latched goal?
            if self._latched is not None:
                st = states.get(self._latched)
                if st in _TERMINAL:
                    if st == _CANCELED:
                        # A CANCELED latch while another goal is active is a
                        # preemption (a newer goal replaced ours) — re-latch
                        # instead of falsely reporting we stopped.
                        others = [u for u, s2 in states.items()
                                  if s2 in (_ACCEPTED, _EXECUTING)
                                  and u not in self._ignore and u != self._latched]
                        if others:
                            self._ignore.add(self._latched)
                            self._latched = others[-1]
                            st = None
                    if st is not None and st in _TERMINAL:
                        fire = (self._place, st)
                        self._active = False
                        self._latched = None
                        self._place = None
        if fire:
            self._fire(*fire)

    def _check_timeout(self) -> bool:
        fire: Optional[tuple] = None
        with self._lock:
            if self._active and (time.time() - self._armed_at) > _GOAL_TIMEOUT_S:
                fire = (self._place, "timeout")
                self._active = False
                self._latched = None
                self._place = None
        if fire:
            self._fire(*fire)
            return True
        return False

    # ── feedback + arbiter release ───────────────────────────
    def _fire(self, place: Optional[str], status: Any) -> None:
        place = place or "the destination"
        if status == _SUCCEEDED:
            event = "nav_arrived"
            cmd = (f"You have arrived at '{place}'. Briefly tell the user "
                   "you've arrived, in your normal Khaleeji style.")
        elif status == _CANCELED:
            event = "nav_canceled"
            cmd = (f"Navigation to '{place}' was canceled. Briefly acknowledge "
                   "it if relevant.")
        elif status == "timeout":
            event = "nav_failed"
            cmd = (f"You could not confirm reaching '{place}' — it is taking "
                   "too long or the path is blocked. Briefly tell the user you "
                   "couldn't get there.")
        else:  # ABORTED / unknown
            event = "nav_failed"
            cmd = (f"You could NOT reach '{place}' — the path was blocked or "
                   "planning failed. Briefly apologise and say you couldn't "
                   "get there.")
        log.info("goal terminal: place=%s status=%s → %s", place, status, event)
        # Free the legs first (a missed release locks them — make it loud).
        arb = _ARBITER
        if arb is None:
            try:
                from Project.Sanad.dashboard.routes import _arbiter as arb
            except Exception:  # noqa: BLE001
                arb = None
        if arb is not None:
            try:
                arb.release_nav()
            except Exception as exc:  # noqa: BLE001
                log.error("release_nav failed after %s: %s", event, exc)
        else:
            log.error("arbiter unavailable — could not release nav after %s", event)
        # Tell Gemini (via the supervisor's stdin push to the child).
        try:
            from Project.Sanad.main import live_sub
            if live_sub is not None and hasattr(live_sub, "send_state"):
                live_sub.send_state(event, cmd)
        except Exception as exc:  # noqa: BLE001
            log.debug("nav feedback inject failed: %s", exc)


_MON = _GoalMonitor()


def arm_goal(place: str) -> None:
    """Begin watching for the arrival/failure of a goal driving to ``place``."""
    try:
        _MON.arm(place)
    except Exception as exc:  # noqa: BLE001
        log.debug("arm_goal failed: %s", exc)


def disarm() -> None:
    """Stop watching the current goal (e.g. on an explicit cancel)."""
    try:
        _MON.disarm()
    except Exception as exc:  # noqa: BLE001
        log.debug("disarm failed: %s", exc)


async def _cancel_once() -> bool:
    try:
        import websockets
    except Exception:  # noqa: BLE001
        return False
    try:
        async with websockets.connect(_ROSBRIDGE_URL, ping_interval=None) as ws:
            # Zero goal_id + zero stamp == cancel ALL goals (CancelGoal convention).
            await ws.send(json.dumps({
                "op": "call_service",
                "service": _CANCEL_SERVICE,
                "type": "action_msgs/srv/CancelGoal",
                "args": {"goal_info": {"goal_id": {"uuid": [0] * 16},
                                       "stamp": {"sec": 0, "nanosec": 0}}},
            }))
            try:
                await asyncio.wait_for(ws.recv(), timeout=3.0)
            except Exception:
                pass
            return True
    except Exception as exc:  # noqa: BLE001
        log.debug("cancel ws failed: %s", exc)
        return False


def request_cancel() -> bool:
    """Send a real Nav2 action CancelGoal (cancel-all) over rosbridge so the
    robot actually stops. Blocking; call via asyncio.to_thread. Best-effort."""
    try:
        return asyncio.run(_cancel_once())
    except Exception as exc:  # noqa: BLE001
        log.debug("request_cancel failed: %s", exc)
        return False
