"""LocoController — manual G1 locomotion via the Unitree LocoClient (N2 Phase 1).

Ported from the proven scripts in G1_Lootah/Controller (g1_mode_controller.py,
keyboard_controller.py, hanger_boot_sequence.py). Design notes:

* **One DDS init per process.** The arm controller owns the single
  `ChannelFactoryInitialize(0, nic)` (motion/arm_controller.py). This class
  NEVER initialises DDS — it lazily builds its `LocoClient` /
  `MotionSwitcherClient` only after `arm._initialized` is True.
* **Default DISARMED.** `_armed` starts False every boot and gates every WRITE
  method. Reads (status / fsm / joints), E-STOP and disarm are ALWAYS allowed.
* **StopMove watchdog.** Continuous `Move(..., True)` never self-terminates, so a
  daemon thread StopMoves if no `move()` refresh arrives within
  `watchdog_timeout_sec`. The frontend re-sends setpoints at ~10 Hz, so a tab
  close / network drop trips the watchdog within the timeout.
* **Velocity caps.** Symmetric clamp on vx/vy/vyaw — Walk 0.6, Run 1.2.
* **Allow-anytime-warn.** move/step never hard-block on FSM; if not walk-ready
  they still execute but return a `warning`.
* **Sim fallback.** When `unitree_sdk2py` is absent (workstation), every write
  returns `{"simulated": True}` (never raises) so the whole UI is testable.

SDK facts confirmed from source — do not "fix" them:
* `LocoClient.Move(vx, vy, vyaw, True)` — the continuous-mode kwarg is misspelled
  `continous_move` (one n); we pass it POSITIONALLY to avoid a TypeError.
* `LocoClient` has NO StandUp()/Squat() — use SetFsmId(4)/SetFsmId(2).
* FSM id / mode are read via the private RPC `bot._Call(7001/7002, "{}")`.
"""

from __future__ import annotations

import json
import threading
import time
from typing import Any, Optional

from Project.Sanad.core.config_loader import section as _cfg_section
from Project.Sanad.core.logger import get_logger

log = get_logger("loco_controller")

# -- SDK import (optional) -----------------------------------------------------
try:
    from unitree_sdk2py.g1.loco.g1_loco_client import LocoClient
    from unitree_sdk2py.comm.motion_switcher.motion_switcher_client import (
        MotionSwitcherClient,
    )
    _HAS_SDK = True
except ImportError:
    LocoClient = None
    MotionSwitcherClient = None
    _HAS_SDK = False
    log.warning("Unitree SDK not available — LocoController in simulation mode")

# LocoClient general RPC api-ids for FSM read-back (stable across SDK builds).
ROBOT_API_ID_LOCO_GET_FSM_ID = 7001
ROBOT_API_ID_LOCO_GET_FSM_MODE = 7002

# G1 29-DoF joint names for indices 12-28 (0-11 legs, 12-14 waist, 15-21 left
# arm, 22-28 right arm). Used by the Diagnostics joint read-out.
JOINT_NAMES = {
    12: "WAIST_YAW", 13: "WAIST_ROLL", 14: "WAIST_PITCH",
    15: "L_SHOULDER_PITCH", 16: "L_SHOULDER_ROLL", 17: "L_SHOULDER_YAW",
    18: "L_ELBOW", 19: "L_WRIST_ROLL", 20: "L_WRIST_PITCH", 21: "L_WRIST_YAW",
    22: "R_SHOULDER_PITCH", 23: "R_SHOULDER_ROLL", 24: "R_SHOULDER_YAW",
    25: "R_ELBOW", 26: "R_WRIST_ROLL", 27: "R_WRIST_PITCH", 28: "R_WRIST_YAW",
}

# Discrete step pad — (vx, vy, vyaw) sign per direction; magnitude is
# step_speed_frac * cap_walk (a gentle single step).
_STEP_DIRS = {
    "forward":      (1.0, 0.0, 0.0),
    "backward":     (-1.0, 0.0, 0.0),
    "slide_left":   (0.0, 1.0, 0.0),
    "slide_right":  (0.0, -1.0, 0.0),
    "rotate_left":  (0.0, 0.0, 1.0),
    "rotate_right": (0.0, 0.0, -1.0),
}

_POSTURES = (
    "zero_torque", "damp", "stand_up", "squat", "sit",
    "low_stand", "high_stand", "lie_to_stand",
)


class LocoController:
    """Thread-safe manual locomotion control with a simulation fallback."""

    def __init__(self, arm=None):
        self._arm = arm                 # shared ArmController (owns the ONE DDS init)
        self._bot = None                # LocoClient (lazy)
        self._msc = None                # MotionSwitcherClient (lazy)
        self._lc_ready = False
        self._lock = threading.RLock()  # serialise all loco client WRITE calls
        self._armed = False             # in-memory MANUAL gate — OFF every boot

        self._cur_v = (0.0, 0.0, 0.0)   # last commanded (vx, vy, vyaw)
        self._teleop_active = False
        self._last_msc_mode: Optional[str] = None

        # watchdog
        self._last_move_ts = 0.0
        self._wd_thread: Optional[threading.Thread] = None
        self._wd_stop = threading.Event()
        self._wd_stop.set()             # not running until armed
        # Monotonic stop-generation counter, bumped under _lock by
        # estop/stop/disarm. move()/step()/prep_mode() capture it at start and
        # bail the instant it changes — so E-STOP preempts an in-flight motion
        # immediately AND can never be silently "un-cancelled" by a concurrent
        # command (a lock-free Event clear() could; an int compare under the
        # lock cannot).
        self._stop_gen = 0
        # Serializes the discrete blocking operations (step/prep_mode) so two
        # can't overlap and interleave Move commands. Continuous teleop move()
        # is intentionally NOT guarded by this.
        self._discrete_busy = False

        cfg = _cfg_section("motion", "loco_controller")
        self._cap_walk = float(cfg.get("cap_walk", 0.6))
        self._cap_run = float(cfg.get("cap_run", 1.2))
        self._lin_step = float(cfg.get("lin_step", 0.05))
        self._ang_step = float(cfg.get("ang_step", 0.2))
        self._wd_timeout = float(cfg.get("watchdog_timeout_sec", 0.5))
        self._block_window = float(cfg.get("arm_block_window_sec", 1.5))
        self._step_dur = float(cfg.get("step_duration_sec", 0.6))
        self._step_frac = float(cfg.get("step_speed_frac", 0.5))
        self._loco_timeout = float(cfg.get("loco_timeout_sec", 10.0))
        self._msc_timeout = float(cfg.get("msc_timeout_sec", 5.0))

    # ── client lifecycle ─────────────────────────────────────────────────────

    def _ensure_client(self) -> bool:
        """Lazily build LocoClient + MotionSwitcherClient. Returns readiness.

        Never initialises DDS — requires the shared arm to have already run the
        single ChannelFactoryInitialize.
        """
        if not _HAS_SDK:
            return False
        if self._lc_ready:
            return True
        if self._arm is None or not getattr(self._arm, "_initialized", False):
            return False
        with self._lock:
            if self._lc_ready:
                return True
            try:
                bot = LocoClient()
                bot.SetTimeout(self._loco_timeout)
                bot.Init()
                msc = MotionSwitcherClient()
                msc.SetTimeout(self._msc_timeout)
                msc.Init()
                self._bot = bot
                self._msc = msc
                self._lc_ready = True
                log.info("LocoClient + MotionSwitcherClient ready")
            except Exception as exc:
                log.error("LocoClient init failed: %s", exc)
                self._lc_ready = False
        return self._lc_ready

    def _safe_call(self, name: str, fn, *a, **kw):
        try:
            return True, fn(*a, **kw)
        except Exception as exc:
            log.error("%s failed: %s", name, exc)
            return False, None

    def _rpc_get_int(self, api_id: int):
        bot = self._bot
        if bot is None:
            return None
        try:
            code, data = bot._Call(api_id, "{}")
            if code == 0 and data:
                return json.loads(data).get("data")
        except Exception:
            pass
        return None

    @staticmethod
    def _clamp(v: float, cap: float) -> float:
        return max(-cap, min(cap, float(v)))

    # ── FSM / readiness ──────────────────────────────────────────────────────

    def fsm_id(self):
        return self._rpc_get_int(ROBOT_API_ID_LOCO_GET_FSM_ID)

    def fsm_mode(self):
        return self._rpc_get_int(ROBOT_API_ID_LOCO_GET_FSM_MODE)

    def _walk_ready_warning(self) -> Optional[str]:
        """allow-anytime-warn: None when ready, else a human message."""
        if not self._lc_ready:
            return None
        fid = self.fsm_id()
        fmode = self.fsm_mode()
        if fid == 200 and fmode not in (None, 2):
            return None
        return (f"Robot not in walk-ready FSM (id={fid}, mode={fmode}). "
                f"Command sent anyway.")

    # ── arm flag + watchdog ──────────────────────────────────────────────────

    def is_armed(self) -> bool:
        return self._armed

    def movement_active(self) -> bool:
        """True when the robot may be walking: manual armed, teleop active, OR a
        move/step issued within the block window. Used as the arm's motion-block
        predicate so the arm never replays while the robot is (or just was)
        moving — regardless of whether the MANUAL gate or the GEMINI gate
        (Phase 3 voice dispatch, which calls move/step directly) triggered it."""
        if self._armed or self._teleop_active:
            return True
        return (time.monotonic() - self._last_move_ts) < self._block_window

    def arm_movement(self) -> dict:
        """Unlock manual control. Cancels any in-flight arm motion first so the
        arm and locomotion are never active simultaneously (movement wins)."""
        try:
            if self._arm is not None and getattr(self._arm, "is_busy", False):
                log.info("arming movement — cancelling in-flight arm motion")
                self._arm.cancel()
        except Exception:
            log.exception("arm.cancel() on arm_movement failed")
        with self._lock:
            self._armed = True
        self._start_watchdog()
        log.info("movement ARMED")
        return {"ok": True, "armed": True}

    def disarm_movement(self) -> dict:
        with self._lock:
            self._stop_gen += 1          # break any in-flight step/prep/move
            self._armed = False
            self._teleop_active = False
        self._wd_stop.set()
        try:
            self._raw_stop()
        except Exception:
            log.exception("StopMove on disarm failed")
        log.info("movement DISARMED")
        return {"ok": True, "armed": False}

    def _start_watchdog(self):
        self._wd_stop.clear()
        if self._wd_thread is None or not self._wd_thread.is_alive():
            self._wd_thread = threading.Thread(
                target=self._watchdog_loop, daemon=True, name="loco-watchdog")
            self._wd_thread.start()

    def _watchdog_loop(self):
        period = max(0.02, min(0.1, self._wd_timeout / 2.0))
        while not self._wd_stop.is_set():
            fire = False
            park = False
            # Read-and-decide under the lock (atomic check-then-act); the actual
            # StopMove runs after release so the critical section stays tiny.
            with self._lock:
                if self._teleop_active and (time.monotonic() - self._last_move_ts) > self._wd_timeout:
                    self._teleop_active = False
                    fire = True
                # Self-park once there's nothing left to guard. The Gemini
                # dispatch path uses step() directly and never calls
                # disarm_movement(), so without this the watchdog would spin for
                # the rest of the process lifetime after the first voice step.
                if not self._armed and not self._teleop_active and not self._discrete_busy:
                    park = True
            if fire:
                log.warning("watchdog: teleop setpoint stale (>%.2fs) — StopMove",
                            self._wd_timeout)
                try:
                    self._raw_stop()
                except Exception:
                    log.exception("watchdog StopMove failed")
            if park:
                # Nothing left to guard — stop the thread (a later move/step
                # re-arms it via _start_watchdog()). Done AFTER any stale-stop
                # above so we never skip a pending StopMove.
                self._wd_stop.set()
                break
            self._wd_stop.wait(period)

    def _raw_stop(self) -> bool:
        """Issue StopMove if the client is up; no-op in sim. Lock-light."""
        if not self._lc_ready or self._bot is None:
            return False
        with self._lock:
            ok, _ = self._safe_call("StopMove", self._bot.StopMove)
        return ok

    # ── movement ─────────────────────────────────────────────────────────────

    def move(self, vx: float, vy: float, vyaw: float, run: bool = False) -> dict:
        cap = self._cap_run if run else self._cap_walk
        cvx, cvy, cvyaw = self._clamp(vx, cap), self._clamp(vy, cap), self._clamp(vyaw, cap)
        capped = (cvx, cvy, cvyaw) != (float(vx), float(vy), float(vyaw))
        warning = self._walk_ready_warning()
        sent = {"vx": cvx, "vy": cvy, "vyaw": cvyaw}

        with self._lock:
            my_gen = self._stop_gen          # capture under lock

        if not self._ensure_client():
            with self._lock:                 # sim: record intent for UI/watchdog
                self._cur_v = (cvx, cvy, cvyaw)
                self._last_move_ts = time.monotonic()
                self._teleop_active = True
            self._start_watchdog()
            return {"ok": True, "sent": sent, "capped": capped,
                    "warning": warning, "simulated": True}
        with self._lock:
            # If an E-STOP / stop / disarm landed since we captured my_gen, do NOT
            # (re)command velocity — and do NOT stamp the motion flags (so a
            # cancelled tick doesn't extend the arm-block window).
            if self._stop_gen != my_gen:
                return {"ok": False, "cancelled": True, "sent": sent,
                        "capped": capped, "warning": warning, "simulated": False}
            self._cur_v = (cvx, cvy, cvyaw)
            self._last_move_ts = time.monotonic()
            self._teleop_active = True
            self._safe_call("SetBalanceMode", self._bot.SetBalanceMode, 1)
            ok, _ = self._safe_call("Move", self._bot.Move, cvx, cvy, cvyaw, True)
        self._start_watchdog()
        return {"ok": bool(ok), "sent": sent, "capped": capped,
                "warning": warning, "simulated": False}

    def stop_move(self) -> dict:
        """Halt translation/rotation. Allowed even when disarmed."""
        with self._lock:
            self._stop_gen += 1
            self._teleop_active = False
        if not self._ensure_client():
            return {"ok": True, "simulated": True}
        ok = self._raw_stop()
        return {"ok": bool(ok), "simulated": False}

    def estop(self) -> dict:
        """Emergency stop = StopMove only (no Damp / FSM change → keeps posture).
        ALWAYS allowed, even disarmed and in sim. Bumps the stop generation so any
        in-flight move()/step()/prep_mode() bails immediately (no lock wait)."""
        with self._lock:
            self._stop_gen += 1
            self._teleop_active = False
            self._cur_v = (0.0, 0.0, 0.0)
        if not self._ensure_client():
            log.warning("E-STOP (sim)")
            return {"ok": True, "simulated": True}
        ok = self._raw_stop()
        log.warning("E-STOP — StopMove issued")
        return {"ok": bool(ok), "simulated": False}

    def step(self, direction: str) -> dict:
        """Discrete one-step pad: Move for step_duration then StopMove.
        Blocking (~step_duration); call via asyncio.to_thread from the route.

        The sleep loop does NOT hold self._lock, so E-STOP / StopMove (which take
        the lock briefly) preempt it immediately; the loop also bails the moment
        the stop generation changes."""
        if direction not in _STEP_DIRS:
            return {"ok": False, "reason": f"unknown direction: {direction}"}
        sx, sy, syaw = _STEP_DIRS[direction]
        k = self._cap_walk * self._step_frac
        vx, vy, vyaw = sx * k, sy * k, syaw * k
        warning = self._walk_ready_warning()
        with self._lock:
            if self._discrete_busy:
                return {"ok": False, "dir": direction, "reason": "busy",
                        "warning": warning, "simulated": not self._lc_ready}
            self._discrete_busy = True
            my_gen = self._stop_gen
            self._last_move_ts = time.monotonic()
            self._teleop_active = True
        self._start_watchdog()
        if not self._ensure_client():
            with self._lock:
                self._teleop_active = False
                self._discrete_busy = False
            return {"ok": True, "dir": direction, "warning": warning, "simulated": True}
        try:
            with self._lock:
                if self._stop_gen != my_gen:        # stopped before we began
                    return {"ok": False, "dir": direction, "cancelled": True,
                            "warning": warning, "simulated": False}
                self._safe_call("SetBalanceMode", self._bot.SetBalanceMode, 1)
                self._safe_call("Move", self._bot.Move, vx, vy, vyaw, True)
            t_end = time.monotonic() + self._step_dur
            while time.monotonic() < t_end:
                if self._stop_gen != my_gen:
                    break
                with self._lock:
                    self._last_move_ts = time.monotonic()  # keep watchdog fed
                time.sleep(0.05)
        finally:
            with self._lock:
                self._safe_call("StopMove", self._bot.StopMove)
                self._teleop_active = False
                self._discrete_busy = False
        return {"ok": True, "dir": direction, "warning": warning, "simulated": False}

    # ── postures / modes ─────────────────────────────────────────────────────

    def prep_mode(self) -> dict:
        """PREP — StopMove → Damp → StandUp(FSM4) → height ramp → BalanceStand(0).
        Exact order from g1_mode_controller.prep_mode, minus the blocking input().
        Blocking (~1s); call via asyncio.to_thread."""
        if not self._ensure_client():
            return {"ok": True, "mode": "prep", "simulated": True}
        with self._lock:
            if self._discrete_busy:
                return {"ok": False, "mode": "prep", "reason": "busy", "simulated": False}
            self._discrete_busy = True
            my_gen = self._stop_gen
            self._safe_call("StopMove", self._bot.StopMove)
            self._safe_call("Damp", self._bot.Damp)
            self._safe_call("SetFsmId(4)", self._bot.SetFsmId, 4)
        try:
            # Height ramp OUTSIDE the lock so E-STOP can preempt at any time.
            h = 0.02
            while h <= 0.5 + 1e-9:
                if self._stop_gen != my_gen:
                    log.warning("PREP cancelled (E-STOP)")
                    return {"ok": False, "mode": "prep", "cancelled": True, "simulated": False}
                with self._lock:
                    self._safe_call("SetStandHeight", self._bot.SetStandHeight, round(h, 3))
                time.sleep(0.03)
                h += 0.02
            with self._lock:
                self._safe_call("BalanceStand", self._bot.BalanceStand, 0)
                self._safe_call("SetStandHeight", self._bot.SetStandHeight, 0.22)
        finally:
            with self._lock:
                self._discrete_busy = False
        log.info("PREP complete")
        return {"ok": True, "mode": "prep", "simulated": False}

    def ready_start_mode(self) -> dict:
        """READY = PREP then Start (FSM 200 / balance engaged)."""
        self.prep_mode()
        if not self._ensure_client():
            return {"ok": True, "mode": "ready", "simulated": True}
        with self._lock:
            if hasattr(self._bot, "Start"):
                ok, _ = self._safe_call("Start", self._bot.Start)
            else:
                ok, _ = self._safe_call("SetFsmId(200)", self._bot.SetFsmId, 200)
        log.info("READY/START complete")
        return {"ok": bool(ok), "mode": "ready", "simulated": False}

    def posture(self, name: str) -> dict:
        if name not in _POSTURES:
            return {"ok": False, "reason": f"unknown posture: {name}"}
        if not self._ensure_client():
            return {"ok": True, "posture": name, "simulated": True}
        bot = self._bot
        with self._lock:
            if name == "zero_torque":
                ok, _ = self._safe_call("ZeroTorque", bot.ZeroTorque)
            elif name == "damp":
                ok, _ = self._safe_call("Damp", bot.Damp)
            elif name == "stand_up":
                ok, _ = self._safe_call("SetFsmId(4)", bot.SetFsmId, 4)
            elif name == "squat":
                ok, _ = self._safe_call("SetFsmId(2)", bot.SetFsmId, 2)
            elif name == "sit":
                ok, _ = self._safe_call("Sit", bot.Sit)
            elif name == "low_stand":
                ok, _ = self._safe_call("LowStand", bot.LowStand)
            elif name == "high_stand":
                ok, _ = self._safe_call("HighStand", bot.HighStand)
            elif name == "lie_to_stand":
                if hasattr(bot, "Lie2StandUp"):
                    ok, _ = self._safe_call("Lie2StandUp", bot.Lie2StandUp)
                else:
                    ok, _ = self._safe_call("SetFsmId(702)", bot.SetFsmId, 702)
            else:  # unreachable (guarded above)
                ok = False
        return {"ok": bool(ok), "posture": name, "simulated": False}

    def set_balance_mode(self, mode: int) -> dict:
        if not self._ensure_client():
            return {"ok": True, "balance_mode": int(mode), "simulated": True}
        with self._lock:
            ok, _ = self._safe_call("SetBalanceMode", self._bot.SetBalanceMode, int(mode))
        return {"ok": bool(ok), "balance_mode": int(mode), "simulated": False}

    def set_stand_height(self, h: float) -> dict:
        if not self._ensure_client():
            return {"ok": True, "height": float(h), "simulated": True}
        with self._lock:
            ok, _ = self._safe_call("SetStandHeight", self._bot.SetStandHeight, float(h))
        return {"ok": bool(ok), "height": float(h), "simulated": False}

    # ── MotionSwitcher ───────────────────────────────────────────────────────

    def msc_check(self) -> dict:
        if not self._ensure_client() or self._msc is None:
            return {"mode_name": None, "simulated": not self._lc_ready}
        try:
            ret = self._msc.CheckMode()
            name = None
            if isinstance(ret, tuple) and len(ret) >= 2 and isinstance(ret[1], dict):
                name = ret[1].get("name")
            elif isinstance(ret, dict):
                name = ret.get("name")
            self._last_msc_mode = name
            return {"mode_name": name}
        except Exception as exc:
            log.error("msc_check failed: %s", exc)
            return {"mode_name": None}

    def msc_select_ai(self) -> dict:
        if not self._ensure_client() or self._msc is None:
            return {"ok": True, "simulated": True}
        with self._lock:
            ok, _ = self._safe_call("SelectMode(ai)", self._msc.SelectMode, "ai")
        return {"ok": bool(ok), "simulated": False}

    def msc_release(self) -> dict:
        if not self._ensure_client() or self._msc is None:
            return {"ok": True, "simulated": True}
        with self._lock:
            ok, _ = self._safe_call("ReleaseMode", self._msc.ReleaseMode)
        return {"ok": bool(ok), "simulated": False}

    def reconnect(self) -> dict:
        """Drop and rebuild Loco + MSC clients (does NOT re-init the DDS factory)."""
        with self._lock:
            self._bot = None
            self._msc = None
            self._lc_ready = False
        ok = self._ensure_client()
        return {"ok": bool(ok), "lc_ready": self._lc_ready}

    # ── reads ────────────────────────────────────────────────────────────────

    def joints(self) -> dict:
        q: list = []
        try:
            if self._arm is not None:
                q = self._arm.get_current_q()
        except Exception:
            q = []
        out = []
        for idx in range(12, 29):
            val = q[idx] if idx < len(q) else 0.0
            out.append({"idx": idx, "name": JOINT_NAMES.get(idx, f"motor_{idx}"),
                        "q": float(val)})
        return {"joints": out}

    def status(self) -> dict:
        # Polling /status lazily brings up the client once arm DDS is ready.
        self._ensure_client()
        fid = self.fsm_id() if self._lc_ready else None
        fmode = self.fsm_mode() if self._lc_ready else None
        walk_ready = bool(self._lc_ready and fid == 200 and fmode not in (None, 2))
        return {
            "sdk_available": _HAS_SDK,
            "lc_ready": self._lc_ready,
            "armed": self._armed,
            "fsm_id": fid,
            "fsm_mode": fmode,
            "walk_ready": walk_ready,
            "msc_mode": self._last_msc_mode,
            "teleop_active": self._teleop_active,
            "last_velocity": {"vx": self._cur_v[0], "vy": self._cur_v[1], "vyaw": self._cur_v[2]},
            "caps": {"walk": self._cap_walk, "run": self._cap_run},
            "arm_initialized": bool(self._arm is not None and getattr(self._arm, "_initialized", False)),
        }

    # ── shutdown helper ──────────────────────────────────────────────────────

    def shutdown(self):
        """Best-effort StopMove + disarm for process shutdown.

        Uses _raw_stop() (NOT estop()) so teardown never builds a brand-new
        LocoClient: estop() → _ensure_client() would lazily construct a client
        and run bot.Init() (a DDS RPC) during interpreter teardown when we were
        armed-but-never-built (Enable movement clicked, never moved, then
        Ctrl+C). _raw_stop() no-ops when no client was ever created. Bump the
        stop generation so any in-flight motion bails immediately."""
        with self._lock:
            self._stop_gen += 1
            self._teleop_active = False
            self._cur_v = (0.0, 0.0, 0.0)
        try:
            self._raw_stop()          # no-op when _bot is None — never re-inits
        except Exception:
            log.exception("StopMove on shutdown failed")
        finally:
            self.disarm_movement()
