"""Tests for binary frame protocol."""

import asyncio
import pytest

from rpipe.protocol import (
    pack_frame,
    read_frame,
    FramingError,
    RunRequest,
    TYPE_RUN_REQ,
    TYPE_STDOUT,
    TYPE_STDERR,
    TYPE_EXIT,
    TYPE_ERROR,
    TYPE_META,
    TYPE_STDIN,
    TYPE_CANCEL,
)


class TestPackFrame:
    """Test frame encoding."""

    def test_pack_simple_frame(self):
        """Pack a simple frame with no payload."""
        frame = pack_frame(TYPE_STDOUT, run_id=1)
        assert isinstance(frame, bytes)
        assert len(frame) > 5  # At least length + type + run_id

    def test_pack_with_payload(self):
        """Pack a frame with payload."""
        payload = b"hello world"
        frame = pack_frame(TYPE_STDOUT, run_id=1, payload=payload)
        assert isinstance(frame, bytes)
        assert len(frame) == 4 + 1 + 4 + len(payload)  # length + type + run_id + payload

    def test_pack_frame_format(self):
        """Verify frame format: [4-byte length][type][run_id][payload]."""
        payload = b"test"
        frame = pack_frame(TYPE_STDOUT, run_id=42, payload=payload)

        # Frame should be: 4 (length) + 1 (type) + 4 (run_id) + 4 (payload) = 13 bytes
        assert len(frame) == 13

        # First 4 bytes should be length (big-endian u32)
        import struct

        (length,) = struct.unpack("!I", frame[:4])
        assert length == 1 + 4 + 4  # type + run_id + payload

    def test_pack_different_run_ids(self):
        """Different run_ids produce different frames."""
        frame1 = pack_frame(TYPE_STDOUT, run_id=1, payload=b"data")
        frame2 = pack_frame(TYPE_STDOUT, run_id=2, payload=b"data")
        assert frame1 != frame2

    def test_pack_invalid_ftype(self):
        """Invalid ftype raises FramingError."""
        with pytest.raises(FramingError, match="ftype"):
            pack_frame(256, run_id=1)  # ftype > 255

        with pytest.raises(FramingError, match="ftype"):
            pack_frame(-1, run_id=1)  # ftype < 0

    def test_pack_invalid_run_id(self):
        """Invalid run_id raises FramingError."""
        with pytest.raises(FramingError, match="run_id"):
            pack_frame(TYPE_STDOUT, run_id=-1)

        with pytest.raises(FramingError, match="run_id"):
            pack_frame(TYPE_STDOUT, run_id=0x100000000)  # > 2^32-1

    def test_pack_invalid_payload_type(self):
        """Payload must be bytes."""
        with pytest.raises(FramingError, match="payload"):
            pack_frame(TYPE_STDOUT, run_id=1, payload="string")

        with pytest.raises(FramingError, match="payload"):
            pack_frame(TYPE_STDOUT, run_id=1, payload=123)

    def test_pack_large_payload(self):
        """Pack large payload."""
        large_payload = b"x" * (10 * 1024 * 1024)  # 10MB
        frame = pack_frame(TYPE_STDOUT, run_id=1, payload=large_payload)
        assert len(frame) == 4 + 1 + 4 + len(large_payload)

    def test_pack_empty_payload(self):
        """Pack frame with empty payload (default)."""
        frame = pack_frame(TYPE_STDOUT, run_id=1)
        assert len(frame) == 4 + 1 + 4  # No payload


class TestReadFrame:
    """Test frame decoding."""

    @pytest.mark.asyncio
    async def test_read_simple_frame(self):
        """Read a simple frame."""
        # Pack a frame
        packed = pack_frame(TYPE_STDOUT, run_id=1, payload=b"hello")

        # Create reader from bytes
        reader = asyncio.StreamReader()
        reader.feed_data(packed)
        reader.feed_eof()

        # Read frame
        ftype, run_id, payload = await read_frame(reader)

        assert ftype == TYPE_STDOUT
        assert run_id == 1
        assert payload == b"hello"

    @pytest.mark.asyncio
    async def test_read_frame_roundtrip(self):
        """Pack and read frame; verify roundtrip."""
        original_payload = b"test data with \x00 null bytes \xff high bytes"
        packed = pack_frame(TYPE_STDERR, run_id=42, payload=original_payload)

        reader = asyncio.StreamReader()
        reader.feed_data(packed)
        reader.feed_eof()

        ftype, run_id, payload = await read_frame(reader)

        assert ftype == TYPE_STDERR
        assert run_id == 42
        assert payload == original_payload

    @pytest.mark.asyncio
    async def test_read_multiple_frames(self):
        """Read multiple frames in sequence."""
        frames = [
            pack_frame(TYPE_STDOUT, run_id=1, payload=b"line1"),
            pack_frame(TYPE_STDOUT, run_id=1, payload=b"line2"),
            pack_frame(TYPE_EXIT, run_id=1, payload=b"\x00\x00\x00\x00"),  # exit code 0
        ]

        reader = asyncio.StreamReader()
        for frame in frames:
            reader.feed_data(frame)
        reader.feed_eof()

        # Read first frame
        ftype1, run_id1, payload1 = await read_frame(reader)
        assert ftype1 == TYPE_STDOUT
        assert payload1 == b"line1"

        # Read second frame
        ftype2, run_id2, payload2 = await read_frame(reader)
        assert ftype2 == TYPE_STDOUT
        assert payload2 == b"line2"

        # Read third frame
        ftype3, run_id3, payload3 = await read_frame(reader)
        assert ftype3 == TYPE_EXIT
        assert payload3 == b"\x00\x00\x00\x00"

    @pytest.mark.asyncio
    async def test_read_multiplexed_frames(self):
        """Read frames with different run_ids (multiplexing)."""
        frames = [
            pack_frame(TYPE_STDOUT, run_id=1, payload=b"run1-out1"),
            pack_frame(TYPE_STDOUT, run_id=2, payload=b"run2-out1"),
            pack_frame(TYPE_STDOUT, run_id=1, payload=b"run1-out2"),
            pack_frame(TYPE_STDOUT, run_id=2, payload=b"run2-out2"),
        ]

        reader = asyncio.StreamReader()
        for frame in frames:
            reader.feed_data(frame)
        reader.feed_eof()

        results = []
        for _ in range(4):
            ftype, run_id, payload = await read_frame(reader)
            results.append((run_id, payload))

        assert results == [
            (1, b"run1-out1"),
            (2, b"run2-out1"),
            (1, b"run1-out2"),
            (2, b"run2-out2"),
        ]

    @pytest.mark.asyncio
    async def test_read_incomplete_length(self):
        """Incomplete length field raises error."""
        reader = asyncio.StreamReader()
        reader.feed_data(b"\x00\x00")  # Only 2 bytes of 4-byte length
        reader.feed_eof()

        with pytest.raises(Exception):  # IncompleteReadError or FramingError
            await read_frame(reader)

    @pytest.mark.asyncio
    async def test_read_incomplete_body(self):
        """Incomplete body raises error."""
        import struct

        reader = asyncio.StreamReader()
        # Send valid length but incomplete body
        reader.feed_data(struct.pack("!I", 100))  # Says body is 100 bytes
        reader.feed_data(b"short")  # But only 5 bytes
        reader.feed_eof()

        with pytest.raises(Exception):  # IncompleteReadError or FramingError
            await read_frame(reader)

    @pytest.mark.asyncio
    async def test_read_large_frame(self):
        """Read frame with large payload."""
        large_payload = b"x" * (1024 * 1024)  # 1MB
        packed = pack_frame(TYPE_STDOUT, run_id=1, payload=large_payload)

        reader = asyncio.StreamReader()
        reader.feed_data(packed)
        reader.feed_eof()

        ftype, run_id, payload = await read_frame(reader)
        assert len(payload) == len(large_payload)
        assert payload == large_payload


class TestRunRequest:
    """Test RunRequest data class."""

    def test_run_request_creation(self):
        """Create RunRequest."""
        req = RunRequest(
            host="example.com",
            cmd="echo hello",
            cwd="/tmp",
            env={"FOO": "bar"},
        )
        assert req.host == "example.com"
        assert req.cmd == "echo hello"
        assert req.cwd == "/tmp"
        assert req.env == {"FOO": "bar"}
        assert req.timeout_s == 60
        assert req.read_only is True

    def test_run_request_to_dict(self):
        """Convert RunRequest to dict."""
        req = RunRequest(
            host="example.com",
            cmd="ls",
            timeout_s=30,
        )
        d = req.to_dict()
        assert d["host"] == "example.com"
        assert d["cmd"] == "ls"
        assert d["timeout_s"] == 30
        assert isinstance(d["env"], dict)

    def test_run_request_from_dict(self):
        """Create RunRequest from dict."""
        d = {
            "host": "example.com",
            "cmd": "echo test",
            "cwd": "/var/log",
            "env": {"KEY": "value"},
            "timeout_s": 45,
            "read_only": False,
        }
        req = RunRequest.from_dict(d)
        assert req.host == "example.com"
        assert req.cmd == "echo test"
        assert req.cwd == "/var/log"
        assert req.env == {"KEY": "value"}
        assert req.timeout_s == 45
        assert req.read_only is False

    def test_run_request_roundtrip(self):
        """to_dict and from_dict roundtrip."""
        original = RunRequest(
            host="test.com",
            cmd="pwd",
            cwd="/home",
            env={"A": "1", "B": "2"},
            username="user",
            timeout_s=120,
        )
        d = original.to_dict()
        restored = RunRequest.from_dict(d)

        assert restored.host == original.host
        assert restored.cmd == original.cmd
        assert restored.cwd == original.cwd
        assert restored.env == original.env
        assert restored.username == original.username
        assert restored.timeout_s == original.timeout_s

    def test_run_request_defaults(self):
        """RunRequest has sensible defaults."""
        req = RunRequest(
            host="example.com",
            cmd="echo hi",
        )
        assert req.cwd is None
        assert req.env is None
        assert req.username is None
        assert req.pty is False
        assert req.stdin_mode == "none"
        assert req.timeout_s == 60
        assert req.read_only is True


class TestFrameTypes:
    """Test that frame type constants are correct."""

    def test_frame_type_values(self):
        """Verify frame type constants."""
        # These values must never change (backward compatibility)
        assert TYPE_RUN_REQ == 10
        assert TYPE_STDIN == 11
        assert TYPE_CANCEL == 7

        assert TYPE_META == 4
        assert TYPE_STDOUT == 1
        assert TYPE_STDERR == 2
        assert TYPE_EXIT == 3
        assert TYPE_ERROR == 5

    def test_frame_types_no_collisions(self):
        """Verify frame types don't collide."""
        types = [
            TYPE_RUN_REQ,
            TYPE_STDIN,
            TYPE_CANCEL,
            TYPE_META,
            TYPE_STDOUT,
            TYPE_STDERR,
            TYPE_EXIT,
            TYPE_ERROR,
        ]
        assert len(types) == len(set(types)), "Frame type collision detected"
