"""Thin HTTP client to the web_nav3 Nav2 stack for SanadV3.

web_nav3 exposes a FastAPI surface (default http://127.0.0.1:8765) that wraps
a ROS2/Nav2 + rosbridge bringup. SanadV3 talks to it over plain HTTP so it
never has to import ROS2 itself.

Design contract: NO method here ever raises into the caller. Every public
method returns either a clean dict {"ok": bool, "error": str|None, ...} or a
NavStatus. If web_nav3 is unreachable/down, callers (dashboard, voice) get a
graceful failure result and keep running.
"""
from __future__ import annotations

import logging
import threading
from dataclasses import dataclass
from typing import Any, Dict, List, Optional

import requests

logger = logging.getLogger("sanadv3.navigation")


@dataclass
class NavStatus:
    """Snapshot of web_nav3 health (from GET /api/status)."""

    bringup_alive: bool = False
    rosbridge_alive: bool = False
    reachable: bool = False
    error: Optional[str] = None
    log_tail: str = ""
    # What the single bringup is doing right now (from /api/status):
    # mode 1 fresh / 2 continue / 3 localize / None idle.
    mode: Optional[int] = None
    active_map: Optional[str] = None
    mode_label: str = "IDLE"
    mapping: bool = False
    localizing: bool = False

    def as_dict(self) -> Dict[str, Any]:
        return {
            "bringup_alive": self.bringup_alive,
            "rosbridge_alive": self.rosbridge_alive,
            "reachable": self.reachable,
            "error": self.error,
            "log_tail": self.log_tail,
            "mode": self.mode,
            "active_map": self.active_map,
            "mode_label": self.mode_label,
            "mapping": self.mapping,
            "localizing": self.localizing,
        }


class WebNav3Client:
    """Loosely-coupled HTTP client to web_nav3.

    Args:
        base_url: web_nav3 FastAPI base, e.g. "http://127.0.0.1:8765".
        robot: robot name, sent as X-Robot-Name header on every request.
        timeout: default per-request timeout (seconds).
    """

    def __init__(
        self,
        base_url: str = "http://127.0.0.1:8765",
        robot: str = "sanad",
        timeout: float = 3.0,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.robot = robot
        self.timeout = float(timeout)
        self._session = requests.Session()
        # requests.Session is not guaranteed thread-safe, but route handlers
        # call us from asyncio.to_thread workers (multiple tabs / overlapping
        # status-poll + goto). Serialize Session access with a lock.
        self._session_lock = threading.Lock()

    # ------------------------------------------------------------------ #
    # internals
    # ------------------------------------------------------------------ #
    def _headers(self) -> Dict[str, str]:
        return {"X-Robot-Name": self.robot}

    def _url(self, path: str) -> str:
        if not path.startswith("/"):
            path = "/" + path
        return self.base_url + path

    def _request(
        self,
        method: str,
        path: str,
        *,
        params: Optional[Dict[str, Any]] = None,
        json_body: Optional[Dict[str, Any]] = None,
        timeout: Optional[float] = None,
    ) -> Dict[str, Any]:
        """Perform a request, always returning {"ok":bool, ...}; never raises."""
        url = self._url(path)
        try:
            with self._session_lock:
                resp = self._session.request(
                    method,
                    url,
                    params=params,
                    json=json_body,
                    headers=self._headers(),
                    timeout=timeout if timeout is not None else self.timeout,
                )
        except requests.RequestException as exc:
            logger.debug("web_nav3 %s %s failed: %s", method, path, exc)
            return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}

        # Try to decode a JSON body either way (errors often carry detail).
        body: Any = None
        try:
            body = resp.json()
        except ValueError:
            body = resp.text

        if not resp.ok:
            detail = body
            if isinstance(body, dict):
                detail = body.get("detail") or body.get("error") or body
            return {
                "ok": False,
                "error": f"HTTP {resp.status_code}: {detail}",
                "status_code": resp.status_code,
                "data": body,
            }

        return {"ok": True, "error": None, "data": body, "status_code": resp.status_code}

    # ------------------------------------------------------------------ #
    # health
    # ------------------------------------------------------------------ #
    def reachable(self) -> bool:
        """Quick liveness check: short-timeout GET /api/status.

        Reachable means web_nav3 ANSWERED — any HTTP response (including a 5xx
        from a running-but-erroring backend) counts. Only a transport failure
        (connection refused / timeout) means unreachable.
        """
        try:
            with self._session_lock:
                self._session.get(
                    self._url("/api/status"),
                    headers=self._headers(),
                    timeout=min(self.timeout, 1.5),
                )
            return True
        except requests.RequestException:
            return False

    def status(self) -> NavStatus:
        """Poll GET /api/status; returns a NavStatus (never raises)."""
        res = self._request("GET", "/api/status")
        if not res["ok"]:
            # A 'status_code' key means web_nav3 answered with an HTTP error
            # (e.g. 500) — it IS reachable, just erroring. Only a transport
            # failure (no status_code) is genuinely unreachable.
            answered = "status_code" in res
            log_tail = ""
            body = res.get("data")
            if answered and isinstance(body, dict):
                log_tail = str(body.get("log_tail", "") or "")
            return NavStatus(reachable=answered, error=res["error"], log_tail=log_tail)
        data = res.get("data") or {}
        if not isinstance(data, dict):
            return NavStatus(reachable=True, error="unexpected status payload")
        return NavStatus(
            bringup_alive=bool(data.get("bringup_alive", False)),
            rosbridge_alive=bool(data.get("rosbridge_alive", False)),
            reachable=True,
            error=None,
            log_tail=str(data.get("log_tail", "") or ""),
            mode=data.get("mode"),
            active_map=data.get("active_map"),
            mode_label=str(data.get("mode_label", "IDLE") or "IDLE"),
            mapping=bool(data.get("mapping", False)),
            localizing=bool(data.get("localizing", False)),
        )

    # ------------------------------------------------------------------ #
    # bringup lifecycle
    # ------------------------------------------------------------------ #
    def start(self, mode: int, db_path: Optional[str] = None) -> Dict[str, Any]:
        """POST /api/start. mode 1=fresh SLAM, 2/3=localization (need db_path)."""
        body: Dict[str, Any] = {"mode": int(mode), "robot": self.robot}
        if db_path is not None:
            body["db_path"] = db_path
        return self._request("POST", "/api/start", json_body=body, timeout=15.0)

    def stop(self) -> Dict[str, Any]:
        """POST /api/stop — SIGINT the running bringup process group."""
        return self._request("POST", "/api/stop", timeout=15.0)

    def load_map(self, db_path: str) -> Dict[str, Any]:
        """POST /api/load_map — VIEW a saved map: stop any running bringup, then
        start LOCALIZE-only (mode 3) against db_path. Use this for 'Load & View'
        so it actually switches to the chosen map instead of attaching to a
        mapping session already in progress."""
        return self._request("POST", "/api/load_map",
                             json_body={"db_path": db_path}, timeout=25.0)

    def shutdown(self) -> Dict[str, Any]:
        """POST /api/shutdown — kill ALL registered web_nav3 processes."""
        return self._request("POST", "/api/shutdown", timeout=15.0)

    # ------------------------------------------------------------------ #
    # places / navigation
    # ------------------------------------------------------------------ #
    def list_places(self, map_name: Optional[str] = None) -> List[Dict[str, Any]]:
        """GET /api/places. Per-MAP when map_name given (each map keeps its own
        places); else the legacy per-robot store. Returns a list (empty on error)."""
        params: Dict[str, Any] = {"robot": self.robot}
        if map_name:
            params["map"] = map_name
        res = self._request("GET", "/api/places", params=params)
        if not res["ok"]:
            logger.debug("list_places failed: %s", res["error"])
            return []
        data = res.get("data")
        return data if isinstance(data, list) else []

    def goto(self, name: str) -> Dict[str, Any]:
        """POST /api/places/goto?name=... — THE primary nav command.

        Navigate to a saved place by name on the currently-running bringup.
        """
        # Backend runs `timeout 5 ros2 topic pub --once` inside subprocess.run(
        # timeout=12); on a Jetson the rclpy cold-start routinely exceeds the
        # default 3s read-timeout, so override to match the backend's cap.
        res = self._request("POST", "/api/places/goto", params={"name": name}, timeout=12.0)
        if res["ok"]:
            return {"ok": True, "error": None, "name": name, "data": res.get("data")}
        return {"ok": False, "error": res["error"], "name": name}

    def cancel(self) -> Dict[str, Any]:
        """Report that server-side goal-cancel is NOT available.

        web_nav3 has no explicit goal-cancel HTTP endpoint, so a true
        per-goal cancel must be performed client-side (browser) over rosbridge
        by sending a CancelGoal to the /navigate_to_pose action server. That
        keeps Nav2/SLAM/bridges alive — only the in-flight goal aborts.

        Server-side there is nothing safe to do here (we must NOT call stop(),
        which SIGINTs the whole bringup process group). Previously this returned
        ok:True, which made the dashboard's rosbridge-down fallback toast
        "cancel sent" while the robot kept driving. Return ok:False with an
        explicit reason so callers never mistake this for a real cancel — the
        only server-side way to halt motion is stop() (tears down bringup).
        """
        return {
            "ok": False,
            "error": "cancel must be done client-side via rosbridge",
            "note": "no server-side goal-cancel; use stop() to tear down bringup",
        }

    def save_here(self, name: str) -> Dict[str, Any]:
        """Save the robot's CURRENT pose as a named place.

        POST /api/places/save_here?name=... — web_nav3 reads the current TF
        pose (requires bringup running; backend takes ``name`` as a query arg).

        CONFIG COUPLING: backend save_here writes the LEGACY places store
        (~/marcus_nav2_test/places.json) regardless of robot, while save_at /
        list_places are robot-scoped and only resolve to that legacy file when
        web_nav3's robot_config.yaml ``robot_name`` equals this client's robot
        (``self.robot``). For all three to share one store, web_nav3's
        robot_name MUST match SANAD_ROBOT_NAME (default 'sanad'); if they drift,
        'Save here' and click-to-add places silently target different files.
        """
        # Same ros2-pub cold-start as goto(): override the 3s default so a slow
        # (but successful) publish isn't reported as a failure.
        return self._request(
            "POST",
            "/api/places/save_here",
            params={"robot": self.robot, "name": name},
            timeout=12.0,
        )

    def save_at(self, name: str, x: float, y: float, yaw: float,
                map_name: Optional[str] = None) -> Dict[str, Any]:
        """Save a named place at an explicit (x, y, yaw) map pose.

        POST /api/places/save_at — writes straight to places.json, so it works
        even with no bringup running (used by click-to-add-place on the map).
        Per-MAP when map_name given. Re-saving an existing name MOVES the place.
        """
        params: Dict[str, Any] = {"robot": self.robot}
        if map_name:
            params["map"] = map_name
        return self._request(
            "POST",
            "/api/places/save_at",
            params=params,
            json_body={
                "name": name,
                "x": float(x),
                "y": float(y),
                "yaw": float(yaw),
            },
        )

    def delete_place(self, name: str, map_name: Optional[str] = None) -> Dict[str, Any]:
        """DELETE /api/places?name=... — remove a saved place (per-map)."""
        params: Dict[str, Any] = {"robot": self.robot, "name": name}
        if map_name:
            params["map"] = map_name
        return self._request("DELETE", "/api/places", params=params)

    def rename_place(self, old: str, new: str,
                     map_name: Optional[str] = None) -> Dict[str, Any]:
        """POST /api/places/rename — rename a saved place (per-map)."""
        params: Dict[str, Any] = {"robot": self.robot}
        if map_name:
            params["map"] = map_name
        return self._request(
            "POST", "/api/places/rename", params=params,
            json_body={"old": old, "new": new},
        )

    # ------------------------------------------------------------------ #
    # map editor — per-map occupancy edit overlay
    # ------------------------------------------------------------------ #
    def get_map_edits(self, map_name: str) -> Dict[str, Any]:
        """GET /api/map_edits?map=... — the saved edit overlay (erased points +
        painted walls) for a map. Returns {ok, edits:[[wx,wy,v],...]}."""
        res = self._request("GET", "/api/map_edits",
                             params={"robot": self.robot, "map": map_name})
        if not res["ok"]:
            return {"ok": False, "error": res["error"], "edits": []}
        data = res.get("data") or {}
        return {"ok": True, "edits": data.get("edits", []) if isinstance(data, dict) else []}

    def save_map_edits(self, map_name: str, edits: list) -> Dict[str, Any]:
        """POST /api/map_edits?map=... — persist the edit overlay for a map.
        edits = list of [world_x, world_y, value] (0=free/erase, 100=wall)."""
        return self._request("POST", "/api/map_edits",
                             params={"robot": self.robot, "map": map_name},
                             json_body={"edits": edits})

    # ------------------------------------------------------------------ #
    # maps / missions
    # ------------------------------------------------------------------ #
    def list_maps(self) -> List[Dict[str, Any]]:
        """GET /api/maps. Returns a list (empty on any error)."""
        res = self._request("GET", "/api/maps", params={"robot": self.robot})
        if not res["ok"]:
            logger.debug("list_maps failed: %s", res["error"])
            return []
        data = res.get("data")
        return data if isinstance(data, list) else []

    def list_missions(self) -> List[Dict[str, Any]]:
        """GET /api/missions. Returns a list (empty on any error)."""
        res = self._request("GET", "/api/missions", params={"robot": self.robot})
        if not res["ok"]:
            logger.debug("list_missions failed: %s", res["error"])
            return []
        data = res.get("data")
        return data if isinstance(data, list) else []

    def run_mission(self, mission_id: Any) -> Dict[str, Any]:
        """POST /api/missions/run — start a saved mission by id."""
        return self._request(
            "POST",
            "/api/missions/run",
            params={"id": mission_id},
            json_body={"id": mission_id, "robot": self.robot},
            timeout=15.0,
        )
