import sys
from pathlib import Path
from types import SimpleNamespace

import numpy as np
import pytest
from lerobot.datasets.lerobot_dataset import LeRobotDataset

from lerobot_robot_ufactory.robots.uf_robot.uf_robot_config import UFRobotConfig
from lerobot_robot_ufactory.scripts import uf_lerobot_record as record_module
from lerobot_robot_ufactory.scripts.uf_lerobot_record import (
    _manual_action_from_observation,
    _update_manual_gripper_key_state,
    _update_manual_gripper_target,
    _prepare_dataset_root,
    _prepare_recording_episode,
    get_cfg,
)


class FakeXArm:
    def __init__(self, robot_ip):
        self.robot_ip = robot_ip
        self.connected = True
        self.axis = 6
        self.error_code = 0
        self.mode = 0
        self.initial_point = [0.0, -30.0, 0.0, 0.0, 0.0, 30.0]
        self._arm = type("FakeArmTransport", (), {"_baud_checkset": False})()
        self.gripper_position = 800
        self.calls = []

    def motion_enable(self, **kwargs):
        self.calls.append(("motion_enable", kwargs))

    def clean_error(self):
        self.calls.append(("clean_error",))

    def set_teach_sensitivity(self, sensitivity):
        self.calls.append(("set_teach_sensitivity", sensitivity))
        return 0

    def set_mode(self, mode):
        self.calls.append(("set_mode", mode))
        self.mode = mode
        return 0

    def set_state(self, state):
        self.calls.append(("set_state", state))
        return 0

    def get_initial_point(self):
        self.calls.append(("get_initial_point",))
        return 0, self.initial_point

    def set_servo_angle(self, **kwargs):
        self.calls.append(("set_servo_angle", kwargs))
        return 0

    def get_err_warn_code(self):
        return 0, [0, 0]

    def set_linear_spd_limit_factor(self, factor):
        self.calls.append(("set_linear_spd_limit_factor", factor))
        return 0

    def set_gripper_enable(self, enable):
        self.calls.append(("set_gripper_enable", enable))
        return 0

    def set_gripper_mode(self, mode):
        self.calls.append(("set_gripper_mode", mode))
        return 0

    def set_gripper_speed(self, speed):
        self.calls.append(("set_gripper_speed", speed))
        return 0

    def set_gripper_position(self, position, **kwargs):
        self.calls.append(("set_gripper_position", position, kwargs))
        self.gripper_position = position
        return 0

    def get_gripper_position(self):
        self.calls.append(("get_gripper_position",))
        return 0, self.gripper_position

    def getset_tgpio_modbus_data(self, data):
        self.calls.append(("getset_tgpio_modbus_data", data))
        return 0, []

    def get_joint_states(self, is_radian=True, num=3):
        positions = np.arange(6, dtype=np.float64)
        velocities = np.zeros(6, dtype=np.float64)
        return 0, [positions, velocities, velocities]

    def disconnect(self):
        self.calls.append(("disconnect",))
        self.connected = False


def test_manual_mode_robot_enters_teaching_mode_without_sending_actions(monkeypatch, tmp_path):
    from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module

    arm = FakeXArm("192.168.1.245")
    monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
    monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)

    config = UFRobotConfig(
        id="test_manual_robot",
        calibration_dir=tmp_path,
        robot_ip=arm.robot_ip,
        robot_dof=6,
        control_space="joint",
        gripper_type=0,
        manual_mode=True,
        teach_sensitivity=4,
    )
    robot = uf_robot_module.UFRobot(config)

    robot.connect()
    assert robot.is_connected
    assert arm.mode == 2
    assert ("set_teach_sensitivity", 4) in arm.calls
    assert robot._initial_point == arm.initial_point
    assert not any(call[0] == "set_servo_angle" for call in arm.calls)

    robot.reset_to_initial()
    reset_calls = [call for call in arm.calls if call[0] == "set_servo_angle"]
    assert reset_calls == [
        (
            "set_servo_angle",
            {
                "angle": arm.initial_point,
                "speed": 20,
                "is_radian": False,
                "wait": True,
            },
        )
    ]
    assert arm.mode == 2

    action = {"J1.pos": 1.0}
    assert robot.send_action(action) is action

    observation = robot.get_observation()
    assert observation["J1.pos"] == 0.0
    assert observation["J6.pos"] == 5.0

    robot.disconnect()
    assert not robot.is_connected
    assert arm.mode == 0
    assert ("disconnect",) in arm.calls

    call_count = len(arm.calls)
    robot.disconnect()
    assert len(arm.calls) == call_count


def test_robot_reset_uses_sdk_initial_point_in_normal_mode(monkeypatch, tmp_path):
    from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module

    arm = FakeXArm("192.168.1.245")
    monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
    monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)

    config = UFRobotConfig(
        id="test_normal_robot",
        calibration_dir=tmp_path,
        robot_ip=arm.robot_ip,
        robot_dof=6,
        control_space="joint",
        gripper_type=0,
    )
    robot = uf_robot_module.UFRobot(config)
    assert not hasattr(config, "start_joints")
    assert not hasattr(config, "start_tcp_pose")
    robot.connect()

    reset_calls = [call for call in arm.calls if call[0] == "set_servo_angle"]
    assert reset_calls == [
        (
            "set_servo_angle",
            {
                "angle": arm.initial_point,
                "speed": 20,
                "is_radian": False,
                "wait": True,
            },
        )
    ]

    robot.disconnect()


def test_normal_mode_waits_for_gripper_to_open_before_control(monkeypatch, tmp_path):
    from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module

    arm = FakeXArm("192.168.1.245")
    arm.gripper_position = 400
    monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
    monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)

    config = UFRobotConfig(
        id="test_wait_for_gripper",
        calibration_dir=tmp_path,
        robot_ip=arm.robot_ip,
        robot_dof=6,
        control_space="joint",
        gripper_type=1,
    )
    robot = uf_robot_module.UFRobot(config)
    robot.connect()

    open_calls = [call for call in arm.calls if call[0] == "set_gripper_position"]
    assert open_calls == [("set_gripper_position", 800, {"wait": True})]
    assert robot._last_gripper_command == 0.0

    before_writes = len(
        [call for call in arm.calls if call[0] == "getset_tgpio_modbus_data"]
    )
    robot._send_gripper_action(0.0)
    after_writes = len(
        [call for call in arm.calls if call[0] == "getset_tgpio_modbus_data"]
    )
    assert after_writes == before_writes

    robot.disconnect()


def test_gripper_rs485_commands_are_rate_limited(monkeypatch, tmp_path):
    from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module

    arm = FakeXArm("192.168.1.245")
    monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
    monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)
    config = UFRobotConfig(
        id="test_gripper_rate_limit",
        calibration_dir=tmp_path,
        robot_ip=arm.robot_ip,
        robot_dof=6,
        control_space="joint",
        gripper_type=1,
        gripper_command_interval_s=0.1,
        gripper_error_log_path=None,
    )
    robot = uf_robot_module.UFRobot(config)
    robot.connect()

    robot._send_gripper_action(0.2)
    robot._send_gripper_action(0.4)
    writes = [call for call in arm.calls if call[0] == "set_gripper_position"]
    # One initialization/open write and one runtime write; the second runtime
    # target is coalesced by the RS485 rate limiter.
    assert len(writes) == 2
    assert writes[-1][2] == {
        "wait": False,
        "wait_motion": False,
        "check_baud": False,
        "check_err": False,
    }

    robot.disconnect()


def test_manual_mode_config_rejects_cartesian_control(tmp_path):
    with pytest.raises(ValueError, match="control_space='joint'"):
        UFRobotConfig(
            id="test_manual_robot",
            calibration_dir=tmp_path,
            robot_dof=6,
            control_space="cartesian",
            manual_mode=True,
        )


def test_manual_gripper_speed_is_configurable_and_non_negative(tmp_path):
    config = UFRobotConfig(
        id="test_manual_robot",
        calibration_dir=tmp_path,
        robot_dof=6,
        manual_mode=True,
        manual_gripper_speed=0.25,
    )
    assert config.manual_gripper_speed == 0.25

    with pytest.raises(ValueError, match="manual_gripper_speed"):
        UFRobotConfig(
            id="test_manual_robot",
            calibration_dir=tmp_path,
            robot_dof=6,
            manual_mode=True,
            manual_gripper_speed=-0.1,
        )


def test_manual_action_filters_non_action_observation_fields():
    observation = {
        "J1.pos": 1.0,
        "J1.vel": 2.0,
        "gripper.pos": 0.5,
        "camera": np.zeros((2, 2, 3), dtype=np.uint8),
    }
    action_features = {"J1.pos": float, "gripper.pos": float}

    assert _manual_action_from_observation(observation, action_features) == {
        "J1.pos": 1.0,
        "gripper.pos": 0.5,
    }


def test_manual_gripper_keys_update_target_in_expected_direction_and_bounds():
    key_state = {"close": False, "open": False}

    _update_manual_gripper_key_state(type("Key", (), {"char": "C"})(), True, key_state)
    assert key_state == {"close": True, "open": False}
    assert _update_manual_gripper_target(0.5, key_state, speed=1.0, fps=10) == pytest.approx(0.6)

    _update_manual_gripper_key_state(type("Key", (), {"char": "C"})(), False, key_state)
    _update_manual_gripper_key_state(type("Key", (), {"char": "o"})(), True, key_state)
    assert _update_manual_gripper_target(0.05, key_state, speed=1.0, fps=10) == 0.0

    _update_manual_gripper_key_state(type("Key", (), {"char": "c"})(), True, key_state)
    assert _update_manual_gripper_target(0.99, key_state, speed=1.0, fps=10) == 0.99


def test_manual_mode_initializes_gripper_without_opening_and_sends_only_gripper(monkeypatch, tmp_path):
    from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module

    arm = FakeXArm("192.168.1.245")
    monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
    monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)

    config = UFRobotConfig(
        id="test_manual_gripper_robot",
        calibration_dir=tmp_path,
        robot_ip=arm.robot_ip,
        robot_dof=6,
        control_space="joint",
        gripper_type=1,
        manual_mode=True,
        gripper_error_log_path=None,
    )
    robot = uf_robot_module.UFRobot(config)
    robot.connect()

    assert ("set_gripper_enable", True) in arm.calls
    assert ("set_gripper_mode", 0) in arm.calls
    assert ("set_gripper_speed", 5000) in arm.calls
    assert not any(call[0] == "set_gripper_position" for call in arm.calls)

    robot.send_action({"J1.pos": 1.0, "gripper.pos": 0.5})

    runtime_writes = [call for call in arm.calls if call[0] == "set_gripper_position"]
    assert len(runtime_writes) == 1
    assert runtime_writes[0][2]["wait_motion"] is False
    assert not any(call[0] == "set_servo_angle" for call in arm.calls)
    robot.disconnect()


def test_gripper_command_is_only_sent_after_target_changes(monkeypatch, tmp_path):
    from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module

    arm = FakeXArm("192.168.1.245")
    monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
    monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)

    config = UFRobotConfig(
        id="test_gripper_command_threshold",
        calibration_dir=tmp_path,
        robot_ip=arm.robot_ip,
        robot_dof=6,
        control_space="joint",
        gripper_type=1,
        manual_mode=True,
        gripper_command_threshold=0.01,
        gripper_command_interval_s=0.0,
        gripper_error_log_path=None,
    )
    robot = uf_robot_module.UFRobot(config)
    robot.connect()

    robot.send_action({"gripper.pos": 0.5})
    robot.send_action({"gripper.pos": 0.505})
    robot.send_action({"gripper.pos": 0.52})

    writes = [call for call in arm.calls if call[0] == "set_gripper_position"]
    assert len(writes) == 2
    robot.disconnect()


def test_manual_record_config_has_no_teleop(monkeypatch):
    config_path = Path("config/manual_mode/xarm7_manual_record_config.yaml").resolve()
    monkeypatch.setattr(
        sys,
        "argv",
        ["uf-lerobot-record", "--config_path", str(config_path)],
    )

    config = get_cfg()

    assert config.robot.manual_mode is True
    assert config.robot.robot_dof == 7
    assert config.robot.manual_gripper_speed == 0.5
    assert config.teleop is None
    assert config.dataset.fps == 30


def test_prepare_dataset_root_leaves_new_root_for_lerobot_create(tmp_path):
    root = tmp_path / "nested" / "dataset"
    cfg = SimpleNamespace(dataset=SimpleNamespace(root=root), resume=False)

    _prepare_dataset_root(cfg)

    assert not root.exists()


def test_prepare_dataset_root_rejects_incomplete_resume(tmp_path):
    root = tmp_path / "dataset"
    (root / "meta").mkdir(parents=True)
    (root / "meta" / "info.json").write_text("{}")
    cfg = SimpleNamespace(dataset=SimpleNamespace(root=root), resume=True)

    with pytest.raises(RuntimeError, match="meta/tasks.parquet"):
        _prepare_dataset_root(cfg)


def test_prepare_dataset_root_rejects_resume_when_root_is_missing(tmp_path):
    cfg = SimpleNamespace(dataset=SimpleNamespace(root=tmp_path / "missing"), resume=True)

    with pytest.raises(RuntimeError, match="does not exist"):
        _prepare_dataset_root(cfg)


def test_prepare_dataset_root_resumes_complete_dataset_without_prompt(tmp_path, monkeypatch):
    root = tmp_path / "dataset"
    (root / "meta" / "episodes" / "chunk-000").mkdir(parents=True)
    (root / "data" / "chunk-000").mkdir(parents=True)
    (root / "meta" / "info.json").write_text("{}")
    (root / "meta" / "tasks.parquet").write_bytes(b"tasks")
    (root / "meta" / "episodes" / "chunk-000" / "file-000.parquet").write_bytes(b"episodes")
    (root / "data" / "chunk-000" / "file-000.parquet").write_bytes(b"data")
    cfg = SimpleNamespace(dataset=SimpleNamespace(root=root), resume=True)
    monkeypatch.setattr(record_module.sys.stdin, "isatty", lambda: True)

    _prepare_dataset_root(cfg)


def test_manual_record_loop_writes_actual_state_as_action(tmp_path):
    class FakeRobot:
        name = "fake_manual_robot"
        robot_type = name
        action_features = {"J1.pos": float, "J2.pos": float}
        observation_features = action_features

        def __init__(self):
            self.observation_count = 0
            self.sent_actions = []

        def get_observation(self):
            self.observation_count += 1
            value = float(self.observation_count)
            return {"J1.pos": value, "J2.pos": value + 1}

        def send_action(self, action):
            self.sent_actions.append(action.copy())
            return action

    robot = FakeRobot()
    action_pipeline, robot_pipeline, observation_pipeline = record_module.make_default_processors()
    features = record_module.combine_feature_dicts(
        record_module.aggregate_pipeline_dataset_features(
            pipeline=action_pipeline,
            initial_features=record_module.create_initial_features(action=robot.action_features),
            use_videos=False,
        ),
        record_module.aggregate_pipeline_dataset_features(
            pipeline=observation_pipeline,
            initial_features=record_module.create_initial_features(
                observation=robot.observation_features
            ),
            use_videos=False,
        ),
    )
    dataset = LeRobotDataset.create(
        "test/manual-record",
        fps=30,
        features=features,
        root=tmp_path / "dataset",
        robot_type=robot.robot_type,
        use_videos=False,
    )

    record_module.record_loop(
        robot=robot,
        events={"exit_early": False},
        fps=30,
        teleop_action_processor=action_pipeline,
        robot_action_processor=robot_pipeline,
        robot_observation_processor=observation_pipeline,
        dataset=dataset,
        control_time_s=0.001,
        single_task="test task",
        manual_mode=True,
    )

    assert dataset.episode_buffer["size"] == 1
    assert robot.sent_actions == [{"J1.pos": 2.0, "J2.pos": 3.0}]
    assert dataset.episode_buffer["action"][0].tolist() == [2.0, 3.0]

    dataset.save_episode()
    dataset.finalize()


def test_manual_recording_episode_resets_before_recording():
    class FakeRobot:
        def __init__(self):
            self.calls = []

        def reset_to_initial(self):
            self.calls.append("reset_to_initial")

    robot = FakeRobot()

    _prepare_recording_episode(robot, teleop=None, is_uf_teleop=False, manual_mode=True)

    assert robot.calls == ["reset_to_initial"]


def test_manual_record_loop_applies_keyboard_gripper_target():
    class FakeRobot:
        name = "fake_manual_robot"
        robot_type = name
        action_features = {"J1.pos": float, "gripper.pos": float}

        def __init__(self):
            self.sent_actions = []

        def get_observation(self):
            return {"J1.pos": 1.0, "gripper.pos": 0.5}

        def send_action(self, action):
            self.sent_actions.append(action.copy())
            return action

    robot = FakeRobot()
    action_pipeline, robot_pipeline, observation_pipeline = record_module.make_default_processors()

    record_module.record_loop(
        robot=robot,
        events={"exit_early": False},
        fps=10,
        teleop_action_processor=action_pipeline,
        robot_action_processor=robot_pipeline,
        robot_observation_processor=observation_pipeline,
        control_time_s=0.001,
        manual_mode=True,
        manual_gripper_keys={"close": True, "open": False},
        manual_gripper_speed=1.0,
    )

    assert robot.sent_actions == [{"J1.pos": 1.0, "gripper.pos": 0.6}]
