#!/usr/bin/env python3
"""
g1_mode_controller.py

Interactive terminal controller for Unitree G1 locomotion + basic upper body (waist/arms).

Key features:
- Menu is generated from a single OPTIONS dictionary (easy to extend).
- Ctrl+C / Quit = StopMove only (keep current posture; NO prep/damp).
- Status prints LIVE joints (deg) from LowState.
- WALK/RUN teleop uses the "old working" keyboard control (pynput).
- PREP mode = stand + balance (NO Start/FSM200).
- READY/START mode = PREP + Start (FSM 200).
- StandUp/Squat/Sit/Lie-to-Stand use SetFsmId() fallback for compatibility.

Run:
  python3 g1_mode_controller.py --iface enp3s0
"""

from __future__ import annotations

import argparse
import sys
import time
from dataclasses import dataclass
from typing import Optional, Dict, Tuple, Callable, Any
from threading import Lock, Event
from collections import OrderedDict

# ---------- Unitree SDK2 ----------
from unitree_sdk2py.core.channel import ChannelFactoryInitialize, ChannelSubscriber
from unitree_sdk2py.g1.loco.g1_loco_client import LocoClient
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowState_  # type: ignore
from unitree_sdk2py.comm.motion_switcher.motion_switcher_client import MotionSwitcherClient

# Optional teleop keyboard:
try:
    from pynput.keyboard import Listener, Key, KeyCode  # type: ignore
    HAVE_PYNPUT = True
except Exception:
    HAVE_PYNPUT = False


# ---------------- Joint map (for status) ----------------
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",
}


def rad2deg(x: float) -> float:
    return float(x) * 180.0 / 3.141592653589793


# ---------------- Live State Monitor ----------------

class LiveStateMonitor:
    """Subscribes LowState and keeps last joint positions."""
    def __init__(self):
        self.lock = Lock()
        self.last_q_rad: Dict[int, float] = {}
        self.msg_count = 0
        self.last_rx_time = 0.0
        self.first_msg_evt = Event()

    def cb(self, msg: LowState_):
        motor_state = getattr(msg, "motor_state", None)
        if motor_state is None:
            return

        with self.lock:
            self.msg_count += 1
            self.last_rx_time = time.time()

            for idx in JOINT_NAMES.keys():
                try:
                    self.last_q_rad[idx] = float(motor_state[idx].q)
                except Exception:
                    pass

        self.first_msg_evt.set()

    def snapshot_deg(self) -> Tuple[int, float, Dict[int, float]]:
        with self.lock:
            age = (time.time() - self.last_rx_time) if self.last_rx_time else 1e9
            qdeg = {i: rad2deg(q) for i, q in self.last_q_rad.items()}
            return self.msg_count, age, qdeg


# ---------------- Upper body (waist/arms) publisher ----------------

@dataclass
class ArmRig:
    pub: object
    cmd: object
    crc: object
    waist_idx: int = 12

    def set_joint(self, idx: int, q: float, kp: float = 60.0, kd: float = 1.5, tau: float = 0.0):
        mc = self.cmd.motor_cmd[idx]
        mc.q = float(q)
        mc.dq = 0.0
        mc.tau = float(tau)
        mc.kp = float(kp)
        mc.kd = float(kd)

    def send(self):
        self.cmd.crc = self.crc.Crc(self.cmd)
        self.pub.Write(self.cmd)


def try_init_arm_sdk() -> Optional[ArmRig]:
    try:
        from unitree_sdk2py.core.channel import ChannelPublisher  # type: ignore
        from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_  # type: ignore
        from unitree_sdk2py.idl.default import unitree_hg_msg_dds__LowCmd_  # type: ignore
        from unitree_sdk2py.utils.crc import CRC  # type: ignore

        pub = ChannelPublisher("rt/arm_sdk", LowCmd_)
        pub.Init()

        cmd = unitree_hg_msg_dds__LowCmd_()
        crc = CRC()

        # enable arm_sdk mode
        cmd.motor_cmd[29].q = 1
        return ArmRig(pub=pub, cmd=cmd, crc=crc)
    except Exception as e:
        print("[arm_sdk] disabled:", e)
        return None


# ---------------- Core helpers ----------------

def init_loco(timeout: float = 10.0) -> LocoClient:
    bot = LocoClient()
    bot.SetTimeout(timeout)
    bot.Init()
    return bot


def init_msc(timeout: float = 5.0) -> MotionSwitcherClient:
    msc = MotionSwitcherClient()
    msc.SetTimeout(timeout)
    msc.Init()
    return msc


def safe_call(name: str, fn: Callable[..., Any], *args, **kwargs) -> Any:
    """
    Unitree python wrappers often return None on success.
    This helper prints [OK] on no-exception; prints [ERR] on exception.
    """
    try:
        ret = fn(*args, **kwargs)
        print(f"[OK] {name}: success (ret={ret})")
        return ret
    except Exception as e:
        code = getattr(e, "code", None)
        payload = getattr(e, "payload", None)
        print(f"[ERR] {name} failed: {repr(e)} code={code} payload={payload}")
        return None


def stop_only(bot: Optional[LocoClient]):
    """Stop walking/teleop velocity but do NOT change mode/posture."""
    if bot is None:
        return
    safe_call("StopMove", bot.StopMove)


def move_cmd(bot: LocoClient, vx: float, vy: float, om: float):
    """
    SDK variations exist. We try a safe call order.
    """
    try:
        bot.Move(vx, vy, om, True)
        return
    except Exception:
        pass

    try:
        bot.Move(vx, vy, om)
        return
    except Exception:
        pass

    try:
        bot.Move(vx, vy, om, continuous_move=True)  # type: ignore
        return
    except Exception as e:
        print("[ERR] Move failed in all signatures:", repr(e))


def fsm_set(bot: LocoClient, fsm_id: int, label: str):
    """
    Prefer direct methods if present; fallback to SetFsmId(fsm_id).
    """
    if hasattr(bot, label):
        fn = getattr(bot, label)
        return safe_call(label, fn)

    # common fallback in some sdk builds
    if hasattr(bot, "SetFsmId"):
        return safe_call(f"{label} (FSM {fsm_id}) via SetFsmId({fsm_id})", bot.SetFsmId, fsm_id)

    print(f"[ERR] Neither {label} nor SetFsmId exists in this SDK build.")
    return None


def balance_stand(bot: LocoClient, mode: int = 0):
    """
    Some builds require BalanceStand(balance_mode).
    Others expose SetBalanceMode(0/1).
    """
    if hasattr(bot, "BalanceStand"):
        try:
            return safe_call(f"BalanceStand({mode})", bot.BalanceStand, mode)
        except TypeError:
            return safe_call("BalanceStand()", bot.BalanceStand)

    if hasattr(bot, "SetBalanceMode"):
        return safe_call(f"SetBalanceMode({mode})", bot.SetBalanceMode, mode)

    print("[WARN] No BalanceStand/SetBalanceMode available in this SDK.")
    return None


# ---------------- PREP / READY sequences ----------------

def prep_mode(bot: LocoClient):
    """
    PREP = stand + balance (NO Start/FSM200).
    Matches your tested behavior:
      StopMove -> Damp -> StandUp(FSM4) -> ramp stand height -> BalanceStand(0) -> set final height.
    """
    print("[PREP] stand + balance (NO Start/FSM200) ...")
    safe_call("StopMove", bot.StopMove)
    safe_call("Damp", bot.Damp)

    # StandUp FSM 4
    fsm_set(bot, 4, "StandUp")

    # Stand height ramp (0.02 .. 0.50)
    if hasattr(bot, "SetStandHeight"):
        for h in [x / 100.0 for x in range(2, 51, 2)]:
            safe_call(f"SetStandHeight({h:.2f}m)", bot.SetStandHeight, float(h))
            time.sleep(0.03)

        # set a comfortable final height before balancing
        safe_call("SetStandHeight(0.22m)", bot.SetStandHeight, 0.22)
    else:
        print("[WARN] bot.SetStandHeight not available in this SDK build.")

    # Balance (static stand)
    balance_stand(bot, mode=0)

    # Re-send final height (common trick)
    if hasattr(bot, "SetStandHeight"):
        safe_call("SetStandHeight(0.22m)", bot.SetStandHeight, 0.22)

    print("[PREP] Done. (NO Start)")


def ready_start_mode(bot: LocoClient):
    """
    READY/START = PREP + Start (FSM200).
    """
    print("[READY] stand + balance + start (FSM 200) ...")
    prep_mode(bot)
    if hasattr(bot, "Start"):
        safe_call("Start (FSM 200)", bot.Start)
    else:
        # some builds: SetFsmId(200)
        fsm_set(bot, 200, "Start")
    print("[READY] Done. (FSM200 expected)")


# ---------------- Teleop (old keyboard function) ----------------

def teleop_loop(bot: LocoClient, speed_limit: float):
    if not HAVE_PYNPUT:
        print("[ERR] pynput not installed. Install: pip install pynput")
        return

    LIN_STEP = 0.05
    ANG_STEP = 0.2
    SEND_PERIOD = 0.10  # 10 Hz

    pressed = set()

    def on_press(k):
        if isinstance(k, KeyCode) and k.char:
            pressed.add(k.char.lower())
        else:
            pressed.add(k)

    def on_release(k):
        if isinstance(k, KeyCode) and k.char:
            pressed.discard(k.char.lower())
        else:
            pressed.discard(k)

    def key(name: str) -> bool:
        if name == "space":
            return Key.space in pressed
        if name == "esc":
            return Key.esc in pressed
        return name in pressed

    def clamp(v: float) -> float:
        return max(-speed_limit, min(speed_limit, v))

    vx = vy = om = 0.0
    last_send = 0.0

    print("\n--- TELEOP ---")
    print("Hold keys: W/S (vx), Q/E (vy), A/D (yaw), Space (stop).  ESC to exit.\n")

    listener = Listener(on_press=on_press, on_release=on_release)
    listener.start()

    try:
        while True:
            if key("esc"):
                stop_only(bot)
                break

            if key("w") and not key("s"):
                vx = clamp(vx + LIN_STEP)
            elif key("s") and not key("w"):
                vx = clamp(vx - LIN_STEP)
            else:
                vx = 0.0

            if key("q") and not key("e"):
                vy = clamp(vy + LIN_STEP)
            elif key("e") and not key("q"):
                vy = clamp(vy - LIN_STEP)
            else:
                vy = 0.0

            if key("a") and not key("d"):
                om = clamp(om + ANG_STEP)
            elif key("d") and not key("a"):
                om = clamp(om - ANG_STEP)
            else:
                om = 0.0

            if key("space"):
                vx = vy = om = 0.0

            now = time.time()
            if now - last_send >= SEND_PERIOD:
                # enable gait mode if supported (some SDKs require it for walking)
                if hasattr(bot, "SetBalanceMode"):
                    try:
                        bot.SetBalanceMode(1)
                    except Exception:
                        pass

                move_cmd(bot, vx, vy, om)
                last_send = now
                sys.stdout.write(f"\rvx={vx:+.2f}  vy={vy:+.2f}  yaw={om:+.2f}    ")
                sys.stdout.flush()

            time.sleep(0.005)

    except KeyboardInterrupt:
        stop_only(bot)
        print("\n--- teleop interrupted (StopMove only) ---\n")
    finally:
        listener.stop()
        print("\n--- teleop ended ---\n")


# ---------------- Presets for arms ----------------

LEFT_DOWN = [
    (12, 0.0),
    (15, +0.211),
    (16, +0.181),
    (17, -0.284),
    (18, +0.672),
    (19, -0.379),
    (20, -0.852),
    (21, -0.019),
]
RIGHT_DOWN = [
    (12, 0.0),
    (22, +0.087),
    (23, -0.271),
    (24, +0.323),
    (25, +0.691),
    (26, +0.240),
    (27, -0.771),
    (28, -0.176),
]

def apply_pose(arm: ArmRig, pose, kp: float = 60.0, kd: float = 1.5):
    for j, q in pose:
        arm.set_joint(j, q, kp=kp, kd=kd, tau=0.0)
    arm.send()


# ---------------- Main ----------------

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--iface", required=True, help="NIC connected to G1 (example: enp3s0)")
    ap.add_argument("--timeout", type=float, default=10.0)
    ap.add_argument("--topic", default="", help="LowState topic override (default tries rt/lf/lowstate then rt/lowstate)")
    args = ap.parse_args()

    # DDS init ONCE
    ChannelFactoryInitialize(0, args.iface)

    # Live monitor
    monitor = LiveStateMonitor()
    topics = [args.topic] if args.topic else ["rt/lf/lowstate", "rt/lowstate"]
    sub = None
    last_err = None
    for t in topics:
        try:
            sub = ChannelSubscriber(t, LowState_)
            sub.Init(monitor.cb, 200)
            print(f"[OK] LowState subscribed: {t}")
            break
        except Exception as e:
            last_err = e
            print(f"[WARN] LowState subscribe failed for {t}: {e}", file=sys.stderr)
    if sub is None:
        print(f"[WARN] Could not subscribe LowState. Last error: {last_err}")

    # Init clients
    print("Initializing LocoClient...")
    bot: Optional[LocoClient] = None
    try:
        bot = init_loco(timeout=args.timeout)
    except Exception as e:
        print("[ERR] Loco init failed:", e)

    print("Initializing MotionSwitcherClient...")
    msc: Optional[MotionSwitcherClient] = None
    try:
        msc = init_msc(timeout=5.0)
        status, result = msc.CheckMode()
        print(f"[MSC] Current mode: {result.get('name') or '(none)'}")
    except Exception as e:
        print("[WARN] MotionSwitcher init failed:", e)

    arm = try_init_arm_sdk()

    # Snapshot on start
    if sub is not None and monitor.first_msg_evt.wait(timeout=2.0):
        cnt, age, qdeg = monitor.snapshot_deg()
        print("\n[STATE] Current joint snapshot (deg):")
        print(f"  lowstate_msgs={cnt}  age={age:.2f}s")
        for idx in sorted(qdeg.keys()):
            print(f"  [{idx:02d}] {JOINT_NAMES.get(idx,'?'):<18} {qdeg[idx]:+8.2f}°")
        print()

    def need_bot() -> bool:
        if bot is None:
            print("[ERR] No LocoClient available.")
            return False
        return True

    def need_arm() -> bool:
        if arm is None:
            print("[ERR] arm_sdk not available.")
            return False
        return True

    # ----- Dictionary-driven menu -----
    OPTIONS: "OrderedDict[int, Dict[str, Any]]" = OrderedDict()

    OPTIONS[1] = {"label": "PREP mode (stand + balance)  [NO start/FSM200]",
                  "fn": lambda: prep_mode(bot) if need_bot() else None}

    OPTIONS[2] = {"label": "READY/START mode (stand + balance + start, FSM 200)",
                  "fn": lambda: ready_start_mode(bot) if need_bot() else None}

    OPTIONS[3] = {"label": "ZeroTorque (FSM 0)",
                  "fn": lambda: fsm_set(bot, 0, "ZeroTorque") if need_bot() else None}

    OPTIONS[4] = {"label": "Damp (FSM 1)",
                  "fn": lambda: fsm_set(bot, 1, "Damp") if need_bot() else None}

    OPTIONS[5] = {"label": "StandUp (FSM 4)",
                  "fn": lambda: fsm_set(bot, 4, "StandUp") if need_bot() else None}

    OPTIONS[6] = {"label": "Squat (FSM 2)",
                  "fn": lambda: fsm_set(bot, 2, "Squat") if need_bot() else None}

    OPTIONS[7] = {"label": "Sit (FSM 3)",
                  "fn": lambda: fsm_set(bot, 3, "Sit") if need_bot() else None}

    OPTIONS[8] = {"label": "LowStand",
                  "fn": lambda: safe_call("LowStand", bot.LowStand) if (need_bot() and hasattr(bot, "LowStand")) else print("[ERR] LowStand not available")}

    OPTIONS[9] = {"label": "HighStand",
                  "fn": lambda: safe_call("HighStand", bot.HighStand) if (need_bot() and hasattr(bot, "HighStand")) else print("[ERR] HighStand not available")}

    OPTIONS[10] = {"label": "Lie-to-Stand (FSM 702) (if supported)",
                   "fn": lambda: fsm_set(bot, 702, "Lie2StandUp") if need_bot() else None}

    OPTIONS[11] = {"label": "Walk teleop (keyboard: W/S Q/E A/D, ESC exit)",
                   "fn": lambda: teleop_loop(bot, speed_limit=0.6) if need_bot() else None}

    OPTIONS[12] = {"label": "Run teleop (keyboard: W/S Q/E A/D, ESC exit)",
                   "fn": lambda: teleop_loop(bot, speed_limit=1.2) if need_bot() else None}

    OPTIONS[16] = {"label": "Status (LIVE joints)",
                   "fn": lambda: show_status(bot, arm, msc, sub, monitor)}

    OPTIONS[17] = {"label": "MotionSwitcher: Select mode 'ai' (supported)",
                   "fn": lambda: msc_select_ai(msc)}

    OPTIONS[18] = {"label": "MotionSwitcher: Release current mode",
                   "fn": lambda: msc_release(msc)}

    OPTIONS[19] = {"label": "MotionSwitcher: Show current mode",
                   "fn": lambda: msc_show(msc)}

    OPTIONS[20] = {"label": "Seated mode ON (Sit)",
                   "fn": lambda: fsm_set(bot, 3, "Sit") if need_bot() else None}

    OPTIONS[21] = {"label": "Seated mode OFF (StandUp + PREP)",
                   "fn": lambda: seated_off(bot) if need_bot() else None}

    OPTIONS[22] = {"label": "Reconnect (re-init Loco + MotionSwitcher)",
                   "fn": lambda: reconnect(args, lambda b: set_bot_ref(b), lambda m: set_msc_ref(m))}

    # Helpers need access to outer bot/msc refs:
    def set_bot_ref(new_bot: Optional[LocoClient]):
        nonlocal bot
        bot = new_bot

    def set_msc_ref(new_msc: Optional[MotionSwitcherClient]):
        nonlocal msc
        msc = new_msc


    def print_menu():
        print("\n" + "=" * 70)
        print("G1 MODE CONTROLLER - select option:")
        print("=" * 70)
        for k, v in OPTIONS.items():
            print(f"{k:>3}) {v['label']}")
        print("  0) Quit (StopMove only)")
        print("=" * 70)

    print_menu()

    while True:
        try:
            choice = input("\nSelect option (0-22): ").strip()
        except KeyboardInterrupt:
            stop_only(bot)
            print("\n[EXIT] Ctrl+C → StopMove only. Keeping current posture. Bye.")
            break
        except EOFError:
            stop_only(bot)
            print("\n[EXIT] EOF → StopMove only. Keeping current posture. Bye.")
            break

        if not choice:
            continue

        if choice == "0":
            stop_only(bot)
            print("\n[EXIT] Quit → StopMove only. Keeping current posture. Bye.")
            break

        try:
            opt = int(choice)
        except ValueError:
            print("Please enter a number.")
            continue

        if opt not in OPTIONS:
            print("Unknown option.")
            print_menu()
            continue

        try:
            OPTIONS[opt]["fn"]()
        except KeyboardInterrupt:
            stop_only(bot)
            print("\n[INTERRUPT] Ctrl+C → StopMove only. Keeping current posture.")
        except Exception as e:
            print("[ERR] Action failed:", repr(e))

        print_menu()


# --------- extra action helpers that need current refs ---------

def show_status(bot, arm, msc, sub, monitor):
    print("Status:")
    print(f"  LocoClient:      {'OK' if bot is not None else 'NOT INITIALISED'}")
    print(f"  MotionSwitcher:  {'OK' if msc is not None else 'DISABLED'}")
    if msc is not None:
        try:
            _s, r = msc.CheckMode()
            print(f"  MSC mode:        {r.get('name') or '(none)'}")
        except Exception:
            print("  MSC mode:        (error reading)")
    print(f"  arm_sdk:         {'OK' if arm is not None else 'DISABLED'}")
    print(f"  pynput:          {'OK' if HAVE_PYNPUT else 'NOT INSTALLED'}")

    if sub is None:
        print("  LowState:        NOT SUBSCRIBED")
        return

    cnt, age, qdeg = monitor.snapshot_deg()
    print(f"  LowState:        OK  msgs={cnt}  age={age:.2f}s")
    print("  Current joints (deg):")
    for idx in sorted(qdeg.keys()):
        print(f"    [{idx:02d}] {JOINT_NAMES.get(idx,'?'):<18} {qdeg[idx]:+8.2f}°")


def msc_select_ai(msc: Optional[MotionSwitcherClient]):
    if msc is None:
        print("[ERR] MotionSwitcher not available.")
        return
    ret = safe_call("SelectMode('ai')", msc.SelectMode, "ai")
    # ret could be (7004,None) etc; we just print it from safe_call.


def msc_release(msc: Optional[MotionSwitcherClient]):
    if msc is None:
        print("[ERR] MotionSwitcher not available.")
        return
    safe_call("ReleaseMode()", msc.ReleaseMode)


def msc_show(msc: Optional[MotionSwitcherClient]):
    if msc is None:
        print("[ERR] MotionSwitcher not available.")
        return
    try:
        s, r = msc.CheckMode()
        print(f"[MSC] Current mode: {r.get('name') or '(none)'}")
    except Exception as e:
        print("[ERR] CheckMode failed:", repr(e))


def seated_off(bot: LocoClient):
    print("[SEATED] Leaving seated: StandUp + PREP (no Start)...")
    fsm_set(bot, 4, "StandUp")
    prep_mode(bot)


def reconnect(args, set_bot, set_msc):
    print("Reconnecting LocoClient...")
    try:
        b = init_loco(timeout=args.timeout)
        set_bot(b)
        print("[OK] LocoClient reconnected.")
    except Exception as e:
        print("[ERR] Loco reconnect failed:", repr(e))
        set_bot(None)

    print("Reconnecting MotionSwitcherClient...")
    try:
        m = init_msc(timeout=5.0)
        set_msc(m)
        try:
            _s, r = m.CheckMode()
            print(f"[MSC] Current mode: {r.get('name') or '(none)'}")
        except Exception:
            pass
        print("[OK] MotionSwitcher reconnected.")
    except Exception as e:
        print("[ERR] MotionSwitcher reconnect failed:", repr(e))
        set_msc(None)


if __name__ == "__main__":
    main()
