"""Tests for the pacsys.acnet module (no network, pure unit tests)."""

import asyncio
import struct
import threading
from collections import defaultdict
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from pacsys.acnet import (
    ACNET_HEADER_SIZE,
    ACNET_PEND,
    AcnetConnection,
    AcnetConnectionTCP,
    AcnetError,
    AcnetPacket,
    AcnetReply,
    AcnetRequest,
    NodeStats,
    ReplyId,
    RequestId,
    decode,
    decode_stripped,
    encode,
    node_parts,
    node_value,
)
from pacsys.acnet.async_connection import AsyncAcnetConnectionTCP
from pacsys.acnet.constants import (
    ACNET_FLG_MLT,
    ACNET_FLG_REQ,
    ACNET_FLG_RPY,
    ACNET_FLG_USM,
    CMD_DEFAULT_NODE,
    CMD_IGNORE_REQUEST,
    CMD_KEEPALIVE,
    CMD_NODE_STATS,
    CMD_RENAME_TASK,
    CMD_SEND,
    CMD_TASK_PID,
)
from pacsys.acnet.errors import (
    make_error,
    normalize_error_code,
    parse_error,
)


class TestRad50:
    """Tests for RAD50 encoding/decoding."""

    def test_encode_simple(self):
        """Test encoding simple names."""
        # "DPM" should encode to a specific value
        result = encode("DPM")
        assert isinstance(result, int)
        assert result == encode("DPM   ")  # Should pad with spaces

    def test_decode_roundtrip(self):
        """Test that encode/decode roundtrips correctly."""
        test_names = ["DPM", "ACNET", "TEST", "A", "123456", "A.B$C%"]
        for name in test_names:
            padded = name.ljust(6)[:6]
            encoded = encode(name)
            decoded = decode(encoded)
            assert decoded == padded.upper(), f"Failed for {name}"

    def test_decode_stripped(self):
        """Test decode_stripped removes trailing spaces."""
        encoded = encode("ABC")
        assert decode(encoded) == "ABC   "
        assert decode_stripped(encoded) == "ABC"

    def test_encode_case_insensitive(self):
        """Test that encoding is case insensitive."""
        assert encode("dpm") == encode("DPM")
        assert encode("AcNeT") == encode("ACNET")

    def test_special_characters(self):
        """Test encoding of special characters ($, ., %)."""
        test_names = ["A$B", "X.Y", "P%Q", "$.%"]
        for name in test_names:
            encoded = encode(name)
            decoded = decode_stripped(encoded)
            assert decoded == name.upper()

    def test_numeric_characters(self):
        """Test encoding of numeric characters."""
        assert decode_stripped(encode("123")) == "123"
        assert decode_stripped(encode("A1B2C3")) == "A1B2C3"

    def test_truncation(self):
        """Test that names longer than 6 chars are truncated."""
        long_name = "TOOLONGNAME"
        encoded = encode(long_name)
        decoded = decode(encoded)
        assert decoded == "TOOLON"

    def test_known_values(self):
        """Test against known encoded values."""
        # Space encodes to 0
        assert encode("      ") == 0
        # "A" in first position: index 1 * 40^2 = 1600
        assert encode("A     ") == 1600
        # "A" in second position: index 1 * 40^1 = 40
        assert encode(" A    ") == 40
        # "A" in third position: index 1 * 40^0 = 1
        assert encode("  A   ") == 1


class TestNodeAddressing:
    """Tests for node addressing utilities."""

    def test_node_value(self):
        """Test creating node values from trunk/node."""
        # Trunk 9, node 1
        value = node_value(9, 1)
        assert value == 0x0901

        # Trunk 0, node 255
        value = node_value(0, 255)
        assert value == 0x00FF

    def test_node_parts(self):
        """Test splitting node values."""
        trunk, node = node_parts(0x0901)
        assert trunk == 9
        assert node == 1

        trunk, node = node_parts(0xFF00)
        assert trunk == 255
        assert node == 0

    def test_roundtrip(self):
        """Test node_value/node_parts roundtrip."""
        for trunk in [0, 9, 128, 255]:
            for node in [0, 1, 128, 255]:
                value = node_value(trunk, node)
                t, n = node_parts(value)
                assert t == trunk and n == node


class TestErrorCodes:
    """Tests for error code utilities."""

    def test_make_error(self):
        """Test creating error codes."""
        # ACNET_PEND = 1 + (1 * 256) = 257 = 0x0101
        assert make_error(1, 1) == 0x0101
        # ACNET_ENDMULT = 1 + (2 * 256) = 513 = 0x0201
        assert make_error(1, 2) == 0x0201

    def test_parse_error(self):
        """Test parsing error codes."""
        facility, error_num = parse_error(ACNET_PEND)
        assert facility == 1
        assert error_num == 1

    def test_negative_errors(self):
        """Test negative error numbers."""
        # ACNET_NO_NODE = 1 + (-30 * 256)
        code = make_error(1, -30)
        facility, error_num = parse_error(code)
        assert facility == 1
        assert error_num == -30


class TestNormalizeErrorCode:
    """Tests for unsigned -> signed error code normalization."""

    @pytest.mark.parametrize(
        "input_code,expected",
        [
            (0, 0),
            (1, 1),
            (42, 42),
            (127, 127),
            (128, -128),
            (200, -56),
            (227, -29),
            (255, -1),
            (-1, -1),
            (-29, -29),
        ],
    )
    def test_normalize_error_code(self, input_code, expected):
        assert normalize_error_code(input_code) == expected


class TestRequestReplyIds:
    """Tests for RequestId and ReplyId."""

    def test_reply_id_from_client_and_id(self):
        """Test ReplyId creation from client and message ID."""
        reply_id = ReplyId.from_client_and_id(0x0901, 0x1234)
        assert reply_id.value == 0x09011234


class TestPacketParsing:
    """Tests for ACNET packet parsing."""

    def _make_packet(self, flags: int, status: int = 0, server: int = 0, client: int = 0, data: bytes = b"") -> bytes:
        """Helper to construct a raw ACNET packet."""
        server_task = 0
        client_task_id = 0
        msg_id = 1
        length = ACNET_HEADER_SIZE + len(data)

        # Build header
        header = struct.pack("<H", flags)  # flags - little endian
        header += struct.pack("<h", status)  # status - little endian signed
        header += struct.pack(">H", server)  # server - big endian
        header += struct.pack(">H", client)  # client - big endian
        header += struct.pack("<I", server_task)  # server task - little endian
        header += struct.pack("<H", client_task_id)  # client task id
        header += struct.pack("<H", msg_id)  # message id
        header += struct.pack("<H", length)  # length

        return header + data

    def test_parse_reply(self):
        """Test parsing a reply packet."""
        raw = self._make_packet(ACNET_FLG_RPY, status=0)
        packet = AcnetPacket.parse(raw)

        assert isinstance(packet, AcnetReply)
        assert packet.is_reply()
        assert not packet.is_request()
        assert packet.status == 0
        assert packet.last  # No MLT flag = last reply

    def test_parse_reply_multiple(self):
        """Test parsing a multi-reply packet."""
        raw = self._make_packet(ACNET_FLG_RPY | ACNET_FLG_MLT, status=ACNET_PEND)
        packet = AcnetPacket.parse(raw)

        assert isinstance(packet, AcnetReply)
        assert not packet.last  # MLT flag set = more replies coming
        assert packet.status == ACNET_PEND

    def test_parse_request(self):
        """Test parsing a request packet."""
        raw = self._make_packet(ACNET_FLG_REQ, server=0x0901, client=0x0902)
        packet = AcnetPacket.parse(raw)

        assert isinstance(packet, AcnetRequest)
        assert packet.is_request()
        assert packet.server == 0x0901
        assert packet.client == 0x0902

    def test_parse_request_multiple_reply(self):
        """Test parsing a request expecting multiple replies."""
        raw = self._make_packet(ACNET_FLG_REQ | ACNET_FLG_MLT)
        packet = AcnetPacket.parse(raw)

        assert isinstance(packet, AcnetRequest)
        assert packet.multiple_reply

    def test_parse_message(self):
        """Test parsing an unsolicited message."""
        raw = self._make_packet(ACNET_FLG_USM)
        packet = AcnetPacket.parse(raw)

        assert packet.is_message()
        assert not packet.is_reply()
        assert not packet.is_request()

    def test_parse_with_data(self):
        """Test parsing a packet with payload data."""
        payload = b"Hello ACNET"
        raw = self._make_packet(ACNET_FLG_RPY, data=payload)
        packet = AcnetPacket.parse(raw)

        assert packet.data == payload

    def test_packet_too_short(self):
        """Test that short packets raise an error."""
        with pytest.raises(ValueError, match="too short"):
            AcnetPacket.parse(b"short")

    def test_server_task_name(self):
        """Test getting server task name from packet."""
        raw = self._make_packet(ACNET_FLG_RPY)
        packet = AcnetPacket.parse(raw)
        # Server task is 0, which decodes to spaces
        assert packet.server_task_name == ""

    def test_node_properties(self):
        """Test trunk/node extraction from packet."""
        raw = self._make_packet(ACNET_FLG_RPY, server=0x0901, client=0x0A02)
        packet = AcnetPacket.parse(raw)

        assert packet.server_trunk == 9
        assert packet.server_node == 1
        assert packet.client_trunk == 10
        assert packet.client_node == 2


class TestAcnetReply:
    """Tests for AcnetReply class."""

    def test_success(self):
        """Test success() method."""
        # Create a success reply
        raw = struct.pack("<HhHHIHHH", ACNET_FLG_RPY, 0, 0, 0, 0, 0, 1, 18)
        reply = AcnetPacket.parse(raw)
        assert reply.success()

        # Create a failure reply
        raw = struct.pack("<HhHHIHHH", ACNET_FLG_RPY, -1, 0, 0, 0, 0, 1, 18)
        reply = AcnetPacket.parse(raw)
        assert not reply.success()

    def test_request_id(self):
        """Test getting request ID from reply."""
        # Message ID is at offset 14
        raw = struct.pack("<HhHHIHHH", ACNET_FLG_RPY, 0, 0, 0, 0, 0, 0x1234, 18)
        reply = AcnetPacket.parse(raw)
        assert reply.request_id.id == 0x1234


class TestAcnetRequest:
    """Tests for AcnetRequest class."""

    def test_reply_id_from_status(self):
        """Test reply ID extraction when status is non-zero."""
        # When status field is non-zero, it contains the reply ID
        raw = struct.pack("<HhHHIHHH", ACNET_FLG_REQ, 0x5678, 0, 0, 0, 0, 1, 18)
        request = AcnetPacket.parse(raw)
        assert request.reply_id.value == 0x5678

    def test_reply_id_from_client(self):
        """Test reply ID extraction when status is zero."""
        # When status is 0, reply ID comes from client and message ID
        # Client at big-endian offset 6, msg_id at little-endian offset 14
        raw = struct.pack("<H", ACNET_FLG_REQ)  # flags
        raw += struct.pack("<h", 0)  # status = 0
        raw += struct.pack(">H", 0)  # server
        raw += struct.pack(">H", 0x0901)  # client
        raw += struct.pack("<I", 0)  # server task
        raw += struct.pack("<H", 0)  # client task id
        raw += struct.pack("<H", 0x1234)  # msg id
        raw += struct.pack("<H", 18)  # length

        request = AcnetPacket.parse(raw)
        # Reply ID should be (client << 16) | msg_id
        expected = (0x0901 << 16) | 0x1234
        assert request.reply_id.value == expected

    def test_is_multicast(self):
        """Test multicast detection."""
        # Server node 0xFF indicates multicast
        raw = struct.pack("<H", ACNET_FLG_REQ)  # flags
        raw += struct.pack("<h", 0)  # status
        raw += struct.pack(">H", 0x00FF)  # server = multicast
        raw += struct.pack(">H", 0)  # client
        raw += struct.pack("<I", 0)  # server task
        raw += struct.pack("<H", 0)  # client task id
        raw += struct.pack("<H", 1)  # msg id
        raw += struct.pack("<H", 18)  # length

        request = AcnetPacket.parse(raw)
        assert request.is_multicast()


# =============================================================================
# AcnetConnectionTCP command protocol tests (mocked _xact)
# =============================================================================


def _make_conn():
    """Create a TCP connection with fake handle, bypassing real connect.

    Sets up a real reactor thread and async core so that _run_sync works,
    but mocks the underlying stream writer to avoid real network I/O.
    """
    conn = AcnetConnectionTCP("localhost", port=9999)
    conn._start_reactor()
    conn._async = AsyncAcnetConnectionTCP("localhost", port=9999)
    conn._async._raw_handle = encode("TEST")
    conn._async._handle_name = "TEST"
    conn._async._connected = True
    conn._async._writer = MagicMock()
    conn._async._writer.write = MagicMock()
    conn._async._writer.drain = AsyncMock()
    return conn


@pytest.fixture
def conn():
    c = _make_conn()
    try:
        yield c
    finally:
        c._async._connected = False  # skip disconnect handshake (0.5s timeout)
        c.close()


class TestTCPGetDefaultNode:
    """Tests for get_default_node (cmdDefaultNode)."""

    def test_returns_node_address(self, conn):
        # ack: [ack_code=4][status=0][trunk=12][node=6]
        ack = struct.pack(">HhBB", 4, 0, 12, 6)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)) as mock:
            result = conn.get_default_node()
        assert result == 12 * 256 + 6
        buf = mock.call_args[0][0]
        cmd = struct.unpack(">H", buf[2:4])[0]
        assert cmd == CMD_DEFAULT_NODE

    def test_error_raises(self, conn):
        ack = struct.pack(">HhBB", 4, -1, 0, 0)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)):
            with pytest.raises(AcnetError):
                conn.get_default_node()


class TestTCPRenameTask:
    """Tests for rename_task (cmdRenameTask)."""

    def test_renames_and_updates_handle(self, conn):
        ack = struct.pack(">Hh", 0, 0)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)) as mock:
            conn.rename_task("NEWNAM")
        assert conn.name == "NEWNAM"
        buf = mock.call_args[0][0]
        cmd = struct.unpack(">H", buf[2:4])[0]
        assert cmd == CMD_RENAME_TASK
        # RAD50 name after handle(4) + virtual_node(4) = offset 16
        name_rad50 = struct.unpack(">I", buf[12:16])[0]
        assert decode_stripped(name_rad50) == "NEWNAM"

    def test_empty_name_raises(self, conn):
        with pytest.raises(ValueError):
            conn.rename_task("")

    def test_long_name_raises(self, conn):
        with pytest.raises(ValueError):
            conn.rename_task("TOOLONGNAME")

    def test_error_raises(self, conn):
        ack = struct.pack(">Hh", 0, -1)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)):
            with pytest.raises(AcnetError):
                conn.rename_task("FOO")


class TestTCPSendMessage:
    """Tests for send_message (cmdSend)."""

    def test_sends_with_payload(self, conn):
        ack = struct.pack(">Hh", 0, 0)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)) as mock:
            conn.send_message(node=0x0A06, task="DPM", data=b"\x01\x02")
        buf = mock.call_args[0][0]
        cmd = struct.unpack(">H", buf[2:4])[0]
        assert cmd == CMD_SEND
        # Payload at the end
        assert buf[-2:] == b"\x01\x02"

    def test_error_raises(self, conn):
        ack = struct.pack(">Hh", 0, -3)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)):
            with pytest.raises(AcnetError):
                conn.send_message(0x0A06, "DPM", b"")


class TestTCPIgnoreRequest:
    """Tests for ignore_request (cmdIgnoreRequest)."""

    def test_sends_ignore_and_cleans_up(self, conn):
        # Build a fake AcnetRequest
        raw = struct.pack("<HhHHIHHH", ACNET_FLG_REQ, 0, 0, 0, 0, 0, 42, 18)
        request = AcnetPacket.parse(raw)
        # Register it as an incoming request (on the async core)
        conn._async._requests_in[request.reply_id] = request

        ack = struct.pack(">Hh", 0, 0)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)) as mock:
            conn.ignore_request(request)

        assert request.reply_id not in conn._async._requests_in
        assert request.cancelled
        buf = mock.call_args[0][0]
        cmd = struct.unpack(">H", buf[2:4])[0]
        assert cmd == CMD_IGNORE_REQUEST


class TestTCPGetNodeStats:
    """Tests for get_node_stats (cmdNodeStats)."""

    def test_returns_stats_dataclass(self, conn):
        counters = (10, 20, 30, 40, 50, 60, 100)
        ack = struct.pack(">Hh7I", 7, 0, *counters)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)) as mock:
            stats = conn.get_node_stats()
        assert isinstance(stats, NodeStats)
        assert stats.usm_received == 10
        assert stats.requests_received == 20
        assert stats.replies_received == 30
        assert stats.usm_sent == 40
        assert stats.requests_sent == 50
        assert stats.replies_sent == 60
        assert stats.request_queue_limit == 100
        buf = mock.call_args[0][0]
        cmd = struct.unpack(">H", buf[2:4])[0]
        assert cmd == CMD_NODE_STATS

    def test_error_raises(self, conn):
        ack = struct.pack(">Hh7I", 7, -1, 0, 0, 0, 0, 0, 0, 0)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)):
            with pytest.raises(AcnetError):
                conn.get_node_stats()


class TestTCPGetTaskPid:
    """Tests for get_task_pid (cmdTaskPid)."""

    def test_returns_pid(self, conn):
        ack = struct.pack(">HhI", 6, 0, 12345)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)) as mock:
            pid = conn.get_task_pid("DPM")
        assert pid == 12345
        buf = mock.call_args[0][0]
        cmd = struct.unpack(">H", buf[2:4])[0]
        assert cmd == CMD_TASK_PID

    def test_error_raises(self, conn):
        ack = struct.pack(">HhI", 6, -1, 0)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)):
            with pytest.raises(AcnetError):
                conn.get_task_pid("NOPE")


class TestTCPKeepalive:
    """Tests for keepalive command."""

    def test_keepalive_sends_correct_command(self, conn):
        ack = struct.pack(">Hh", 0, 0)
        with patch.object(conn._async, "_xact", new=AsyncMock(return_value=ack)) as mock:
            conn._send_keepalive()
        buf = mock.call_args[0][0]
        cmd = struct.unpack(">H", buf[2:4])[0]
        assert cmd == CMD_KEEPALIVE


class TestXactCancelledError:
    """_xact must close transport on CancelledError, same as TimeoutError."""

    @pytest.mark.asyncio
    async def test_xact_cleans_up_on_cancelled_error(self):
        """CancelledError during ACK wait must close transport to prevent desync."""
        conn = AsyncAcnetConnectionTCP("localhost", 6802)
        conn._connected = True
        conn._disposed = False
        conn._cmd_lock = asyncio.Lock()
        conn._pending_ack = None
        conn._trace = False

        # Mock transport methods
        conn._send_frame = AsyncMock()
        conn._close_transport = AsyncMock()

        async def cancel_during_wait():
            """Simulate external cancellation while awaiting ACK."""
            await conn._xact(b"\x00\x00\x00\x01", timeout=5.0)

        task = asyncio.create_task(cancel_during_wait())
        await asyncio.sleep(0.01)  # let task start and block on _pending_ack
        task.cancel()

        with pytest.raises(asyncio.CancelledError):
            await task

        # Transport must be closed (same as timeout path)
        conn._close_transport.assert_awaited_once()
        assert conn._connected is False
        assert conn._pending_ack is None


class TestReplyBuffering:
    """Sync connection must buffer replies that arrive before handler registration."""

    @staticmethod
    def _make_reply(msg_id: int, *, last: bool = True, status: int = 0, data: bytes = b"\x01\x02"):
        """Build an AcnetReply with given msg_id (becomes request_id)."""
        flags = ACNET_FLG_RPY if last else (ACNET_FLG_RPY | ACNET_FLG_MLT)
        return AcnetReply(flags, status, 0, 0, 0, 0, msg_id, ACNET_HEADER_SIZE + len(data), data)

    def test_early_reply_is_buffered_not_cancelled(self):
        """Reply arriving before send_request registers handler must be buffered."""
        conn = AcnetConnection.__new__(AcnetConnection)
        conn._requests_out = {}
        conn._requests_out_lock = threading.Lock()
        conn._reply_buffer = defaultdict(list)
        conn._dead_requests = set()

        reply = self._make_reply(42)

        cancel_sent = []
        conn._send_command = lambda *a, **kw: cancel_sent.append(a)

        conn._handle_reply(reply)

        assert len(cancel_sent) == 0
        assert RequestId(42) in conn._reply_buffer
        assert len(conn._reply_buffer[RequestId(42)]) == 1

    def test_dead_request_reply_is_silently_dropped(self):
        """Replies for dead (completed) requests must be silently ignored."""
        conn = AcnetConnection.__new__(AcnetConnection)
        conn._requests_out = {}
        conn._requests_out_lock = threading.Lock()
        conn._reply_buffer = defaultdict(list)
        conn._dead_requests = {RequestId(42)}

        reply = self._make_reply(42)

        cancel_sent = []
        conn._send_command = lambda *a, **kw: cancel_sent.append(a)

        conn._handle_reply(reply)

        assert len(cancel_sent) == 0
        assert RequestId(42) not in conn._reply_buffer
