import os
from struct import pack
from unittest.mock import patch

import pytest

from matterlab_serial_device import SerialDevice
from matterlab_valves.jkem_new import JKemNewValve


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, mocker):
    with patch.object(JKemNewValve, "connect", autospec=True) as connect_spy:
        valve = JKemNewValve(
            com_port=PORT,
            ports={"A": 1, "B": 2, "C": 3},
            valve_num=1,
        )
        connect_spy.assert_called_once_with(valve, 1)
    return valve


def test_initialization_defaults(mock_serial, valve_fixture):
    assert valve_fixture.com_port == PORT
    assert valve_fixture.valve_num == 1
    assert valve_fixture.num_port == 8
    assert valve_fixture.ports == {"A": 1, "B": 2, "C": 3}
    assert valve_fixture.device._connection is mock_serial

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


def test_initialization_skips_connect_when_deferred(mock_serial, mocker):
    connect_spy = mocker.patch.object(JKemNewValve, "connect", autospec=True)

    JKemNewValve(com_port=PORT, ports={"A": 1}, valve_num=2, connect_hardware=False)

    connect_spy.assert_not_called()


def test_calculate_checksum_appends_little_endian_sum():
    raw_cmd = bytearray([204, 1, 74, 0, 0, 221])

    result = JKemNewValve.calculate_checksum(raw_cmd)

    assert result == raw_cmd + pack("<H", sum(raw_cmd))


def test_connect_sends_expected_command(mocker, valve_fixture):
    query_spy = mocker.patch.object(valve_fixture, "query", return_value=b"\x00")

    result = valve_fixture.connect(valve_num=1)

    assert result == 0
    query_spy.assert_called_once_with(
        write_command=JKemNewValve.calculate_checksum(bytearray([204, 1, 74, 0, 0, 221])),
        read_until="",
        remove_from_start=2,
        remove_from_end=5,
    )


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

    with pytest.raises(IOError, match="Connecting valve failed!"):
        valve_fixture.connect(valve_num=1)


def test_execute_valve_returns_zero_on_success(mocker, valve_fixture):
    query_spy = mocker.patch.object(valve_fixture, "query", return_value=b"12345678")

    result = valve_fixture._execute_valve(valve_num=1, port=2)

    assert result == 0
    query_spy.assert_called_once_with(
        write_command=JKemNewValve.calculate_checksum(bytearray([204, 1, 68, 2, 0, 221])),
        read_until="",
    )


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

    assert valve_fixture._execute_valve(valve_num=1, port=2) == -1


def test_query_valve_returns_port_number(mocker, valve_fixture):
    query_spy = mocker.patch.object(valve_fixture, "query", return_value=b"\x03")

    result = valve_fixture._query_valve(valve_num=1)

    assert result == 3
    query_spy.assert_called_once_with(
        write_command=JKemNewValve.calculate_checksum(bytearray([204, 1, 62, 0, 0, 221])),
        read_until="",
        remove_from_start=3,
        remove_from_end=4,
    )


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

    assert valve_fixture._query_valve(valve_num=1) == -1


def test_get_current_port_returns_query_result(mocker, valve_fixture):
    query_spy = mocker.patch.object(valve_fixture, "_query_valve", return_value=2)

    assert valve_fixture.get_current_port(valve_num=1) == 2

    query_spy.assert_called_once_with(valve_num=1)


def test_get_current_port_retries_then_raises(mocker, valve_fixture):
    query_spy = mocker.patch.object(valve_fixture, "_query_valve", side_effect=ValueError)
    sleep_spy = mocker.patch("matterlab_valves.jkem_new.time.sleep")

    with pytest.raises(IOError, match=f"Query port of valve {PORT} No. 1 exceed max try."):
        valve_fixture.get_current_port(valve_num=1, max_try=3)

    assert query_spy.call_count == 3
    assert sleep_spy.call_count == 3


def test_set_current_port_executes_and_logs_success(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve")
    get_port_spy = mocker.patch.object(valve_fixture, "get_current_port", return_value=2)
    info_spy = mocker.patch.object(valve_fixture.logger, "info")
    sleep_spy = mocker.patch("matterlab_valves.jkem_new.time.sleep")

    result = valve_fixture.set_current_port(valve_num=1, port=2)

    assert result is None
    execute_spy.assert_called_once_with(valve_num=1, port=2)
    get_port_spy.assert_called_once_with(valve_num=1)
    info_spy.assert_any_call("Requesting JKem valve move on %s No. %s to port %s", PORT, 1, 2)
    info_spy.assert_any_call("Valve on %s No. %s has been moved to %s", PORT, 1, 2)
    sleep_spy.assert_called_once_with(1)


def test_set_current_port_retries_then_raises(mocker, valve_fixture):
    execute_spy = mocker.patch.object(valve_fixture, "_execute_valve")
    get_port_spy = mocker.patch.object(valve_fixture, "get_current_port", return_value=3)
    sleep_spy = mocker.patch("matterlab_valves.jkem_new.time.sleep")

    with pytest.raises(IOError, match=f"Move port of valve {PORT} No. 1 exceed max try."):
        valve_fixture.set_current_port(valve_num=1, port=2, max_try=3)

    assert execute_spy.call_count == 3
    assert get_port_spy.call_count == 3
    assert sleep_spy.call_count == 3


def test_port_property_reads_from_active_valve(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(valve_num=1)


def test_port_property_sets_active_valve_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(valve_num=1, port=3)
