import os

import pytest

from matterlab_serial_device import SerialDevice
from matterlab_valves.runze_valve import RunzeMotorEventType, RunzeSelectionValve, RunzeValveError


PORT = "/dev/ttyUSB0" if os.name == "posix" else "COM1"


@pytest.fixture
def mock_serial(mocker):
    mock_serial_cls = mocker.patch("matterlab_serial_device.transport.serial.Serial", autospec=True)
    serial_instances = []
    primary_instance = mocker.MagicMock()
    primary_instance.com_port = PORT
    primary_instance._factory = mock_serial_cls
    primary_instance._instances = serial_instances

    def _make_serial_instance(*args, **kwargs):
        if not serial_instances:
            instance = primary_instance
        else:
            instance = mocker.MagicMock()
            instance.com_port = kwargs.get("port", PORT)
        instance.is_open = True
        instance.open.side_effect = lambda: setattr(instance, "is_open", True)
        instance.close.side_effect = lambda: setattr(instance, "is_open", False)
        serial_instances.append(instance)
        return instance

    mock_serial_cls.return_value = primary_instance
    mock_serial_cls.side_effect = _make_serial_instance
    yield primary_instance


@pytest.fixture(autouse=True)
def clear_shared_devices():
    SerialDevice._shared_devices.clear()
    yield
    SerialDevice._shared_devices.clear()


@pytest.fixture
def valve_fixture(mock_serial):
    return RunzeSelectionValve(
        com_port=PORT,
        address=0,
        num_port=10,
        ports={"home": 1, "collect": 2},
    )


def test_initialization_defaults(mock_serial, valve_fixture):
    assert valve_fixture.com_port == PORT
    assert valve_fixture.address == 0
    assert valve_fixture.num_port == 10
    assert valve_fixture.direction_CW is True
    assert valve_fixture.share_port is True
    assert valve_fixture.ports == {"home": 1, "collect": 2}
    assert valve_fixture.device._connection is mock_serial

    mock_serial._factory.assert_called_once_with(
        port=PORT,
        baudrate=9600,
        parity=valve_fixture.parity,
        bytesize=valve_fixture.bytesize,
        timeout=1.0,
        stopbits=valve_fixture.stopbits,
    )


def test_calc_checksum_returns_little_endian_sum():
    raw_cmd = bytearray([0xCC, 0x00, 0x3E, 0x00, 0x00, 0xDD])

    assert RunzeSelectionValve.calc_checksum(raw_cmd) == bytearray([0xE7, 0x01])


def test_execute_valve_wraps_command_and_returns_bytes(mocker, valve_fixture):
    query_spy = mocker.patch.object(valve_fixture, "query", return_value=b"\xcc\x00\xfe\x02\xdd")

    result = valve_fixture._execute_valve(bytearray([0x00, 0x44, 0x02, 0x00]))

    expected_command = bytearray([0xCC, 0x00, 0x44, 0x02, 0x00, 0xDD])
    expected_command += RunzeSelectionValve.calc_checksum(expected_command)
    assert result == bytearray(b"\xcc\x00\xfe\x02\xdd")
    query_spy.assert_called_once_with(write_command=expected_command, return_bytes=True)


def test_execute_valve_raises_on_empty_response(mocker, valve_fixture):
    mocker.patch.object(valve_fixture, "query", return_value=b"")

    with pytest.raises(RunzeValveError, match="Return 0 length data, communication failed"):
        valve_fixture._execute_valve(bytearray([0x00, 0x44, 0x02, 0x00]))


def test_set_address_sends_command_after_confirmation(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve")
    warning_spy = mocker.patch.object(valve_fixture.logger, "warning")
    mocker.patch("builtins.input", return_value="Yes")

    valve_fixture.set_address(5)

    execute_spy.assert_called_once_with(bytearray([0, 0x00, 0xFF, 0xEE, 0xBB, 0xAA, 5, 0x00, 0x00, 0x00]))
    warning_spy.assert_called_once()


def test_set_address_returns_early_when_not_confirmed(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve")
    warning_spy = mocker.patch.object(valve_fixture.logger, "warning")
    mocker.patch("builtins.input", return_value="No")

    valve_fixture.set_address(5)

    execute_spy.assert_not_called()
    warning_spy.assert_called_once_with("Set RS485 address cancelled.")


def test_set_address_raises_for_out_of_range_value(mocker, valve_fixture):
    mocker.patch("builtins.input", return_value="Yes")

    with pytest.raises(ValueError, match="RS485 address out of range \\[0, 0x7f\\]"):
        valve_fixture.set_address(0x80)


def test_check_motor_returns_status_value(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0x00, 0x00]))

    assert valve_fixture.check_motor() == RunzeMotorEventType.Normal_status.value

    execute_spy.assert_called_once_with(bytearray([0, 0x4A, 0x00, 0x00]))


def test_check_motor_raises_for_unknown_status(mocker, valve_fixture):
    mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0x99, 0x00]))

    with pytest.raises(RunzeValveError, match="Unknown motor status"):
        valve_fixture.check_motor()


def test_get_current_port_queries_device(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0x00, 3]))

    assert valve_fixture.get_current_port() == 3
    assert valve_fixture._port == 3

    execute_spy.assert_called_once_with(bytearray([0, 0x3E, 0x00, 0x00]))


def test_set_current_port_uses_direct_move_by_default(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0xFE, 0x00]))
    get_port_spy = mocker.patch.object(valve_fixture, "get_current_port", return_value=2)
    sleep_spy = mocker.patch("matterlab_valves.runze_valve.time.sleep")

    valve_fixture.set_current_port(port=2)

    execute_spy.assert_called_once_with(bytearray([0, 0x44, 2, 0x00]))
    assert get_port_spy.call_count == 2
    sleep_spy.assert_called_once_with(0.5)


def test_set_current_port_uses_clockwise_through_port(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0xFE, 0x00]))
    get_port_spy = mocker.patch.object(valve_fixture, "get_current_port", return_value=2)
    mocker.patch("matterlab_valves.runze_valve.time.sleep")

    valve_fixture.set_current_port(port=2, direction_CW=True)

    execute_spy.assert_called_once_with(bytearray([0, 0xA4, 3, 2]))
    assert get_port_spy.call_count == 2


def test_set_current_port_uses_counter_clockwise_through_port(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0xFE, 0x00]))
    get_port_spy = mocker.patch.object(valve_fixture, "get_current_port", return_value=2)
    mocker.patch("matterlab_valves.runze_valve.time.sleep")

    valve_fixture.set_current_port(port=2, direction_CW=False)

    execute_spy.assert_called_once_with(bytearray([0, 0xA4, 1, 2]))
    assert get_port_spy.call_count == 2


def test_set_current_port_raises_for_out_of_range_port(valve_fixture):
    with pytest.raises(ValueError, match="Port number out of range, default \\[1, 10\\], set to 11"):
        valve_fixture.set_current_port(port=11)


def test_set_current_port_raises_when_task_not_accepted(mocker, valve_fixture):
    mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0x00, 0x00]))

    with pytest.raises(RunzeValveError, match="Set port failed, did not return 'Task being executed'"):
        valve_fixture.set_current_port(port=2, confirmation=False)


def test_set_current_port_raises_when_confirmation_fails(mocker, valve_fixture):
    mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0xFE, 0x00]))
    mocker.patch.object(valve_fixture, "get_current_port", return_value=3)
    mocker.patch("matterlab_valves.runze_valve.time.sleep")

    with pytest.raises(RunzeValveError, match="Set port failed: Expected 2, got 3"):
        valve_fixture.set_current_port(port=2)


def test_set_current_port_updates_cached_port_without_confirmation(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve", return_value=bytearray([0xCC, 0x00, 0xFE, 0x00]))
    get_port_spy = mocker.patch.object(valve_fixture, "get_current_port")

    valve_fixture.set_current_port(port=4, confirmation=False)

    execute_spy.assert_called_once_with(bytearray([0, 0x44, 4, 0x00]))
    get_port_spy.assert_called_once_with()
    assert valve_fixture._port == 4


def test_reset_moves_home(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve")

    valve_fixture.reset(origin=False)

    execute_spy.assert_called_once_with(bytearray([0, 0x45, 0x00, 0x00]))
    assert valve_fixture._port == 1


def test_reset_origin_uses_origin_command(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve")

    valve_fixture.reset(origin=True)

    execute_spy.assert_called_once_with(bytearray([0, 0x4F, 0x00, 0x00]))
    assert valve_fixture._port == 1


def test_set_between_ports_uses_adjacent_port(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve")

    valve_fixture.set_between_ports(3)

    execute_spy.assert_called_once_with(bytearray([0, 0xB4, 3, 4]))


def test_set_between_ports_wraps_from_last_port(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve")

    valve_fixture.set_between_ports(10)

    execute_spy.assert_called_once_with(bytearray([0, 0xB4, 10, 1]))


def test_set_between_ports_raises_for_out_of_range_port(valve_fixture):
    with pytest.raises(ValueError, match="Port number out of range, default \\[1, 10\\], set to 11"):
        valve_fixture.set_between_ports(11)


def test_home_valve_calls_reset(mocker, valve_fixture):
    reset_spy = mocker.patch.object(valve_fixture, "reset")

    valve_fixture._home_valve()

    reset_spy.assert_called_once_with(origin=False)


def test_port_property_reads_current_port(mocker, valve_fixture):
    get_port_spy = mocker.patch.object(valve_fixture, "get_current_port", return_value=2)

    assert valve_fixture.port == 2

    get_port_spy.assert_called_once_with()


def test_port_property_sets_current_port(mocker, valve_fixture):
    set_port_spy = mocker.patch.object(valve_fixture, "set_current_port")

    valve_fixture.port = 3

    set_port_spy.assert_called_once_with(port=3)
