import os
from unittest.mock import MagicMock, call

import pytest
import serial

from matterlab_serial_device import SerialDevice, open_close
from matterlab_serial_device.serial_device import (
    SERIAL_PARITY,
    SERIAL_BYTESIZE,
    SERIAL_STOPBITS,
)
from matterlab_serial_device.transport import SerialTransportConfig

# ATTN: Better tests could be written with the hypothesis library. Leaving for later.

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


@pytest.fixture
def transport_factory():
    instances = []

    def _factory(config: SerialTransportConfig):
        instance = MagicMock()
        instance.com_port = config.port
        instance.config = config
        instance.is_open = True
        instance.open.side_effect = lambda: setattr(instance, "is_open", True)
        instance.close.side_effect = lambda: setattr(instance, "is_open", False)
        instances.append(instance)
        return instance

    _factory.instances = instances
    return _factory


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


@pytest.fixture
def device_fixture(transport_factory):
    """


    Args:
      mock_serial:

    Returns:

    """
    serial_device = SerialDevice(com_port=PORT, transport_factory=transport_factory)
    yield serial_device


def test_init_defaults(transport_factory, device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    # Test class attributes
    assert device_fixture.default_eol == b"\r\n"

    # Test default instance attributes
    assert device_fixture.com_port == PORT
    assert device_fixture.encoding == "utf-8"
    assert device_fixture.baudrate == 9600
    assert device_fixture.parity == serial.PARITY_NONE
    assert device_fixture.bytesize == serial.EIGHTBITS
    assert device_fixture.timeout is None
    assert device_fixture.stopbits == serial.STOPBITS_ONE

    # Test that the mocked Serial class was called with the correct arguments
    assert device_fixture.device.com_port == PORT
    assert device_fixture.device.config == SerialTransportConfig(
        port=PORT,
        baudrate=9600,
        parity=serial.PARITY_NONE,
        bytesize=serial.EIGHTBITS,
        timeout=None,
        stopbits=serial.STOPBITS_ONE,
        extra={},
    )

    # Test that the COM port was closed after device initialization in SerialDevice._init_device()
    device_fixture.device.close.assert_called_once()


def test_init_bytesize(transport_factory):
    """


    Args:
      mock_serial:

    Returns:

    """
    for key, value in SERIAL_BYTESIZE.items():
        serial_device = SerialDevice(com_port=PORT, bytesize=key, transport_factory=transport_factory)
        assert serial_device.bytesize == value


def test_init_parity(transport_factory):
    """


    Args:
      mock_serial:

    Returns:

    """
    for key, value in SERIAL_PARITY.items():
        serial_device = SerialDevice(com_port=PORT, parity=key, transport_factory=transport_factory)
        assert serial_device.parity == value


def test_init_stopbits(transport_factory):
    """


    Args:
      mock_serial:

    Returns:

    """
    for key, value in SERIAL_STOPBITS.items():
        serial_device = SerialDevice(com_port=PORT, stopbits=key, transport_factory=transport_factory)
        assert serial_device.stopbits == value


def test_open_device_comm(device_fixture):
    """


    Args:
      mocker:
      mock_serial:
      device_fixture:

    Returns:

    """
    device_fixture.open_device_comm()

    # Define the expected call sequence
    expected_calls = [call.close(), call.open()]

    # Check that the calls were made in the specified order
    device_fixture.device.assert_has_calls(expected_calls, any_order=False)


def test_close_device_comm(device_fixture):
    """


    Args:
      mocker:
      mock_serial:
      device_fixture:

    Returns:

    """
    device_fixture.close_device_comm()

    # Define the expected call sequence
    expected_calls = [call.close()]

    # Check that the calls were made in the specified order
    device_fixture.device.assert_has_calls(expected_calls, any_order=False)


def test_open_close(transport_factory, device_fixture):
    """


    Args:
      mocker:
      mock_serial:
      device_fixture:

    Returns:

    """

    class DummyDevice(SerialDevice):
        """ """

        @open_close
        def _dummy_method(self):
            """ """
            pass

    dummy_device = DummyDevice(com_port=PORT, transport_factory=transport_factory)
    dummy_device._dummy_method()

    # Define the expected call sequence
    expected_calls = [call.close(), call.open(), call.close()]

    # Check that the calls were made in the specified order
    dummy_device.device.assert_has_calls(expected_calls, any_order=False)


def test_encode(device_fixture):
    """


    Args:
      device_fixture:

    Returns:

    """
    assert device_fixture._encode("test") == b"test"
    assert device_fixture._encode(b"test") == b"test"
    assert device_fixture._encode(bytearray(b"test")) == b"test"
    with pytest.raises(TypeError):
        device_fixture._encode(123)


def test_write(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    device_fixture.write("test")
    device_fixture.device.write.assert_called_once_with(b"test")


def test_read(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    for return_bytes in [True, False]:
        device_fixture.device.read_until.return_value = b"test"
        response = device_fixture.read(return_bytes=return_bytes)

        if return_bytes:
            assert response == b"test"
        else:
            assert response == "test"

        device_fixture.device.read_until.assert_called_once_with(expected=b"\r\n", size=None)
        device_fixture.device.reset_mock()  # Reset call counter at end of loop


def test_read_bytes_default(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    expected_response = b"test"
    device_fixture.device.read_until.return_value = expected_response

    response = device_fixture.read_bytes()

    assert response == expected_response
    device_fixture.device.read_until.assert_called_once_with(expected=b"\r\n", size=None)


def test_read_bytes_until(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    for read_until in [b"end", "end"]:
        device_fixture.read_bytes(read_until=read_until)

        device_fixture.device.read_until.assert_called_once_with(expected=b"end", size=None)
        device_fixture.device.reset_mock()  # Reset call counter at end of loop


def test_read_bytes_num_bytes(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    device_fixture.read_bytes(num_bytes=10)

    device_fixture.device.read_until.assert_called_once_with(expected=b"\r\n", size=10)


def test_read_bytes_remove_from_start(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    device_fixture.device.read_until.return_value = b"test\r\n"
    expected_response = b"test\r\n"[2:]

    response = device_fixture.read_bytes(remove_from_start=2)

    assert response == expected_response
    device_fixture.device.read_until.assert_called_once()


def test_read_bytes_remove_from_end(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    device_fixture.device.read_until.return_value = b"test12"
    expected_response = b"test"

    response = device_fixture.read_bytes(remove_from_end=2)

    assert response == expected_response
    device_fixture.device.read_until.assert_called_once()

    with pytest.raises(ValueError):
        device_fixture.read_bytes(remove_from_end=-1)


def test_read_bytes_remove_from_start_and_end(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    device_fixture.device.read_until.return_value = b"test\r\n"
    expected_response = b"st"

    response = device_fixture.read_bytes(remove_from_start=2, remove_from_end=2)

    assert response == expected_response
    device_fixture.device.read_until.assert_called_once_with(expected=b"\r\n", size=None)

    with pytest.raises(IndexError):
        device_fixture.device.read_until.return_value = b"123456"
        device_fixture.read_bytes(remove_from_start=3, remove_from_end=3)

    with pytest.raises(IndexError):
        device_fixture.device.read_until.return_value = b"123456789"
        device_fixture.read_bytes(remove_from_start=6, remove_from_end=7)


def test_query(device_fixture):
    """


    Args:
      mock_serial:
      device_fixture:

    Returns:

    """
    for command in ["test", b"test", bytearray("test", "utf-8")]:
        device_fixture.device.read_until.return_value = b"test"
        response = device_fixture.query(command)

        assert response == "test"
        device_fixture.device.write.assert_called_once_with(b"test")
        device_fixture.device.read_until.assert_called_once_with(expected=b"\r\n", size=None)
        device_fixture.device.reset_mock()


def test_init_deferred_connection(transport_factory):
    serial_device = SerialDevice(com_port=PORT, connect_hardware=False, transport_factory=transport_factory)

    assert serial_device.device is None
    assert not transport_factory.instances


def test_shared_port_reuses_serial_handle(transport_factory):
    device_a = SerialDevice(com_port=PORT, share_port=True, transport_factory=transport_factory)
    device_b = SerialDevice(com_port=PORT, share_port=True, transport_factory=transport_factory)

    assert device_a.device is device_b.device
    assert len(transport_factory.instances) == 1
    assert SerialDevice._shared_devices


def test_shared_port_different_settings_do_not_reuse_handle(transport_factory):
    device_a = SerialDevice(com_port=PORT, share_port=True, timeout=1.0, transport_factory=transport_factory)
    device_b = SerialDevice(com_port=PORT, share_port=True, timeout=2.0, transport_factory=transport_factory)

    assert device_a.device is not device_b.device
    assert len(transport_factory.instances) == 2


def test_shared_port_disconnect_releases_registry_entry(transport_factory):
    device_a = SerialDevice(com_port=PORT, share_port=True, transport_factory=transport_factory)
    device_b = SerialDevice(com_port=PORT, share_port=True, transport_factory=transport_factory)

    assert len(SerialDevice._shared_devices) == 1

    device_a.disconnect()
    assert len(SerialDevice._shared_devices) == 1

    device_b.disconnect()
    assert not SerialDevice._shared_devices


def test_shared_port_deferred_connection_reuses_existing_handle(transport_factory):
    eager_device = SerialDevice(com_port=PORT, share_port=True, transport_factory=transport_factory)
    deferred_device = SerialDevice(com_port=PORT, share_port=True, connect_hardware=False, transport_factory=transport_factory)

    assert deferred_device.device is None

    deferred_device.open_device_comm()

    assert deferred_device.device is eager_device.device
    assert len(transport_factory.instances) == 1


def test_shared_port_different_transport_factories_do_not_share_handle():
    def factory_a(config: SerialTransportConfig):
        transport = MagicMock()
        transport.com_port = config.port
        transport.is_open = False
        return transport

    def factory_b(config: SerialTransportConfig):
        transport = MagicMock()
        transport.com_port = config.port
        transport.is_open = False
        return transport

    device_a = SerialDevice(com_port=PORT, share_port=True, transport_factory=factory_a)
    device_b = SerialDevice(com_port=PORT, share_port=True, transport_factory=factory_b)

    assert device_a.device is not device_b.device
