import struct

import pytest

from bac_py.encoding.primitives import (
    decode_all_application_values,
    decode_bit_string,
    decode_boolean,
    decode_character_string,
    decode_date,
    decode_double,
    decode_enumerated,
    decode_object_identifier,
    decode_octet_string,
    decode_real,
    decode_signed,
    decode_time,
    decode_unsigned,
    decode_unsigned64,
    encode_application_bit_string,
    encode_application_boolean,
    encode_application_character_string,
    encode_application_date,
    encode_application_double,
    encode_application_enumerated,
    encode_application_null,
    encode_application_object_id,
    encode_application_octet_string,
    encode_application_real,
    encode_application_signed,
    encode_application_time,
    encode_application_unsigned,
    encode_bit_string,
    encode_boolean,
    encode_character_string,
    encode_date,
    encode_double,
    encode_enumerated,
    encode_object_identifier,
    encode_real,
    encode_signed,
    encode_time,
    encode_unsigned,
    encode_unsigned64,
)
from bac_py.encoding.tags import TagClass, decode_tag
from bac_py.types.primitives import BACnetDate, BACnetTime, BitString


class TestUnsigned:
    def test_encode_zero(self):
        assert encode_unsigned(0) == b"\x00"

    def test_encode_one(self):
        assert encode_unsigned(1) == b"\x01"

    def test_encode_255(self):
        assert encode_unsigned(255) == b"\xff"

    def test_encode_256(self):
        result = encode_unsigned(256)
        assert len(result) == 2
        assert result == (256).to_bytes(2, "big")

    def test_encode_65535(self):
        result = encode_unsigned(65535)
        assert len(result) == 2
        assert result == b"\xff\xff"

    def test_encode_large(self):
        result = encode_unsigned(0x01000000)
        assert result == b"\x01\x00\x00\x00"

    def test_encode_negative_raises(self):
        with pytest.raises(ValueError, match="Unsigned integer must be >= 0"):
            encode_unsigned(-1)

    def test_decode_zero(self):
        assert decode_unsigned(b"\x00") == 0

    def test_decode_one(self):
        assert decode_unsigned(b"\x01") == 1

    def test_decode_255(self):
        assert decode_unsigned(b"\xff") == 255

    def test_decode_256(self):
        assert decode_unsigned(b"\x01\x00") == 256

    def test_decode_65535(self):
        assert decode_unsigned(b"\xff\xff") == 65535

    def test_round_trip(self):
        for val in [0, 1, 127, 128, 255, 256, 65535, 65536, 0xFFFFFF, 0xFFFFFFFF]:
            assert decode_unsigned(encode_unsigned(val)) == val


class TestUnsigned64:
    def test_encode_zero(self):
        assert encode_unsigned64(0) == b"\x00"

    def test_encode_small_uses_minimum_bytes(self):
        """Values <= 0xFFFFFFFF should use minimum bytes (same as encode_unsigned)."""
        assert encode_unsigned64(255) == b"\xff"
        assert encode_unsigned64(256) == b"\x01\x00"
        assert encode_unsigned64(0xFFFFFFFF) == b"\xff\xff\xff\xff"

    def test_encode_5_bytes(self):
        val = 0x0100000000  # Just above 4-byte max
        result = encode_unsigned64(val)
        assert len(result) == 5
        assert result == b"\x01\x00\x00\x00\x00"

    def test_encode_6_bytes(self):
        val = 0x010000000000
        result = encode_unsigned64(val)
        assert len(result) == 6

    def test_encode_7_bytes(self):
        val = 0x01000000000000
        result = encode_unsigned64(val)
        assert len(result) == 7

    def test_encode_8_bytes(self):
        val = 0xFFFFFFFFFFFFFFFF
        result = encode_unsigned64(val)
        assert len(result) == 8
        assert result == b"\xff\xff\xff\xff\xff\xff\xff\xff"

    def test_encode_negative_raises(self):
        with pytest.raises(ValueError, match="Unsigned64 integer must be >= 0"):
            encode_unsigned64(-1)

    def test_encode_over_8_bytes_raises(self):
        with pytest.raises(ValueError, match="Unsigned64 integer exceeds 8-byte maximum"):
            encode_unsigned64(0x10000000000000000)

    def test_round_trip(self):
        for val in [
            0,
            1,
            255,
            0xFFFFFFFF,
            0x100000000,
            0xFFFFFFFFFF,
            0xFFFFFFFFFFFFFF,
            0xFFFFFFFFFFFFFFFF,
        ]:
            assert decode_unsigned64(encode_unsigned64(val)) == val

    def test_decode_5_byte_value(self):
        data = b"\x01\x00\x00\x00\x00"
        assert decode_unsigned64(data) == 0x0100000000

    def test_decode_8_byte_value(self):
        data = b"\xff\xff\xff\xff\xff\xff\xff\xff"
        assert decode_unsigned64(data) == 0xFFFFFFFFFFFFFFFF


class TestSigned:
    def test_encode_zero(self):
        assert encode_signed(0) == b"\x00"

    def test_encode_one(self):
        assert encode_signed(1) == b"\x01"

    def test_encode_negative_one(self):
        assert encode_signed(-1) == b"\xff"

    def test_encode_127(self):
        assert encode_signed(127) == b"\x7f"

    def test_encode_128(self):
        result = encode_signed(128)
        assert decode_signed(result) == 128

    def test_encode_negative_128(self):
        result = encode_signed(-128)
        assert decode_signed(result) == -128

    def test_encode_negative_129(self):
        result = encode_signed(-129)
        assert decode_signed(result) == -129

    def test_decode_zero(self):
        assert decode_signed(b"\x00") == 0

    def test_decode_negative_one(self):
        assert decode_signed(b"\xff") == -1

    def test_decode_127(self):
        assert decode_signed(b"\x7f") == 127

    def test_decode_negative_128(self):
        assert decode_signed(b"\x80") == -128

    def test_round_trip_positive(self):
        for val in [0, 1, 127, 128, 32767, 32768, 0x7FFFFF]:
            assert decode_signed(encode_signed(val)) == val

    def test_round_trip_negative(self):
        for val in [-1, -128, -129, -32768, -32769]:
            assert decode_signed(encode_signed(val)) == val


class TestReal:
    def test_encode_zero(self):
        assert encode_real(0.0) == struct.pack(">f", 0.0)

    def test_encode_one(self):
        assert encode_real(1.0) == struct.pack(">f", 1.0)

    def test_encode_negative(self):
        assert encode_real(-1.0) == struct.pack(">f", -1.0)

    def test_always_4_bytes(self):
        assert len(encode_real(72.5)) == 4

    def test_round_trip_zero(self):
        assert decode_real(encode_real(0.0)) == 0.0

    def test_round_trip_positive(self):
        assert decode_real(encode_real(1.0)) == 1.0

    def test_round_trip_negative(self):
        assert decode_real(encode_real(-1.0)) == -1.0

    def test_round_trip_fractional(self):
        assert decode_real(encode_real(72.5)) == pytest.approx(72.5)


class TestDouble:
    def test_encode_zero(self):
        assert encode_double(0.0) == struct.pack(">d", 0.0)

    def test_always_8_bytes(self):
        assert len(encode_double(3.14159)) == 8

    def test_round_trip_zero(self):
        assert decode_double(encode_double(0.0)) == 0.0

    def test_round_trip_large(self):
        val = 1.7976931348623157e308
        assert decode_double(encode_double(val)) == val

    def test_round_trip_negative(self):
        assert decode_double(encode_double(-123456.789)) == pytest.approx(-123456.789)


class TestOctetString:
    def test_decode_identity(self):
        data = b"\xde\xad\xbe\xef"
        assert decode_octet_string(data) == data


class TestCharacterString:
    def test_encode_utf8(self):
        result = encode_character_string("hello")
        assert result[0] == 0x00
        assert result[1:] == b"hello"

    def test_decode_utf8(self):
        data = b"\x00hello"
        assert decode_character_string(data) == "hello"

    def test_round_trip_utf8(self):
        text = "Hello, World!"
        assert decode_character_string(encode_character_string(text)) == text

    def test_round_trip_unicode(self):
        text = "BACnet \u00e9\u00e0\u00fc"
        assert decode_character_string(encode_character_string(text)) == text

    def test_unsupported_charset_encode_raises(self):
        with pytest.raises(ValueError, match="Unsupported BACnet character set"):
            encode_character_string("hello", charset=0x06)

    def test_unknown_charset_decode_falls_back_to_latin1(self):
        """Unknown charset bytes should fall back to latin-1 decode."""
        result = decode_character_string(b"\x06hello")
        assert result == "hello"

    def test_unknown_charset_decode_preserves_high_bytes(self):
        """Latin-1 fallback should preserve high-byte characters."""
        result = decode_character_string(b"\x09\xe9\xe0\xfc")
        assert result == "\u00e9\u00e0\u00fc"

    def test_iso_8859_1(self):
        encoded = encode_character_string("caf\u00e9", charset=0x05)
        assert encoded[0] == 0x05
        assert decode_character_string(encoded) == "caf\u00e9"


class TestEnumerated:
    def test_encode_zero(self):
        assert encode_enumerated(0) == b"\x00"

    def test_encode_small(self):
        assert encode_enumerated(5) == b"\x05"

    def test_encode_large(self):
        result = encode_enumerated(256)
        assert len(result) == 2

    def test_round_trip(self):
        for val in [0, 1, 127, 255, 256, 65535]:
            assert decode_enumerated(encode_enumerated(val)) == val


class TestBitString:
    def test_round_trip_simple(self):
        bs = BitString(b"\xa0", 4)
        encoded = encode_bit_string(bs)
        decoded = decode_bit_string(encoded)
        assert decoded == bs

    def test_round_trip_no_unused(self):
        bs = BitString(b"\xff", 0)
        encoded = encode_bit_string(bs)
        decoded = decode_bit_string(encoded)
        assert decoded == bs

    def test_round_trip_multi_byte(self):
        bs = BitString(b"\xab\xcd", 2)
        encoded = encode_bit_string(bs)
        decoded = decode_bit_string(encoded)
        assert decoded == bs

    def test_encode_format(self):
        bs = BitString(b"\xf0", 4)
        encoded = encode_bit_string(bs)
        assert encoded[0] == 4
        assert encoded[1:] == b"\xf0"


class TestDate:
    def test_encode_normal(self):
        date = BACnetDate(2024, 7, 15, 1)
        encoded = encode_date(date)
        assert encoded == bytes([124, 7, 15, 1])

    def test_decode_normal(self):
        data = bytes([124, 7, 15, 1])
        date = decode_date(data)
        assert date.year == 2024
        assert date.month == 7
        assert date.day == 15
        assert date.day_of_week == 1

    def test_round_trip_normal(self):
        date = BACnetDate(2024, 12, 25, 3)
        assert decode_date(encode_date(date)) == date

    def test_wildcard_year(self):
        date = BACnetDate(0xFF, 6, 1, 0xFF)
        encoded = encode_date(date)
        assert encoded[0] == 0xFF
        decoded = decode_date(encoded)
        assert decoded.year == 0xFF

    def test_round_trip_wildcard(self):
        date = BACnetDate(0xFF, 0xFF, 0xFF, 0xFF)
        assert decode_date(encode_date(date)) == date


class TestTime:
    def test_encode_normal(self):
        t = BACnetTime(14, 30, 45, 50)
        encoded = encode_time(t)
        assert encoded == bytes([14, 30, 45, 50])

    def test_decode_normal(self):
        data = bytes([14, 30, 45, 50])
        t = decode_time(data)
        assert t.hour == 14
        assert t.minute == 30
        assert t.second == 45
        assert t.hundredth == 50

    def test_round_trip_normal(self):
        t = BACnetTime(23, 59, 59, 99)
        assert decode_time(encode_time(t)) == t

    def test_round_trip_wildcard(self):
        t = BACnetTime(0xFF, 0xFF, 0xFF, 0xFF)
        assert decode_time(encode_time(t)) == t

    def test_round_trip_partial_wildcard(self):
        t = BACnetTime(12, 0, 0xFF, 0xFF)
        assert decode_time(encode_time(t)) == t


class TestObjectIdentifier:
    def test_encode(self):
        encoded = encode_object_identifier(8, 1234)
        value = (8 << 22) | 1234
        assert encoded == value.to_bytes(4, "big")

    def test_decode(self):
        value = (8 << 22) | 1234
        data = value.to_bytes(4, "big")
        obj_type, instance = decode_object_identifier(data)
        assert obj_type == 8
        assert instance == 1234

    def test_round_trip(self):
        obj_type, instance = 0, 0
        assert decode_object_identifier(encode_object_identifier(obj_type, instance)) == (0, 0)

    def test_round_trip_max_instance(self):
        obj_type, instance = 8, 0x3FFFFF
        result = decode_object_identifier(encode_object_identifier(obj_type, instance))
        assert result == (obj_type, instance)

    def test_round_trip_large_type(self):
        obj_type, instance = 59, 100
        result = decode_object_identifier(encode_object_identifier(obj_type, instance))
        assert result == (obj_type, instance)


class TestBoolean:
    def test_encode_true(self):
        assert encode_boolean(True) == b"\x01"

    def test_encode_false(self):
        assert encode_boolean(False) == b"\x00"

    def test_decode_true(self):
        assert decode_boolean(b"\x01") is True

    def test_decode_false(self):
        assert decode_boolean(b"\x00") is False

    def test_round_trip_true(self):
        assert decode_boolean(encode_boolean(True)) is True

    def test_round_trip_false(self):
        assert decode_boolean(encode_boolean(False)) is False


class TestApplicationTaggedConvenience:
    def test_encode_application_null(self):
        result = encode_application_null()
        tag, _offset = decode_tag(result, 0)
        assert tag.number == 0
        assert tag.cls == TagClass.APPLICATION
        assert tag.length == 0

    def test_encode_application_boolean_true(self):
        result = encode_application_boolean(True)
        tag, _offset = decode_tag(result, 0)
        assert tag.number == 1
        assert tag.cls == TagClass.APPLICATION
        assert tag.length == 1

    def test_encode_application_boolean_false(self):
        result = encode_application_boolean(False)
        tag, _offset = decode_tag(result, 0)
        assert tag.number == 1
        assert tag.cls == TagClass.APPLICATION
        assert tag.length == 0

    def test_encode_application_unsigned(self):
        result = encode_application_unsigned(42)
        tag, offset = decode_tag(result, 0)
        assert tag.number == 2
        assert tag.cls == TagClass.APPLICATION
        payload = result[offset : offset + tag.length]
        assert decode_unsigned(payload) == 42

    def test_encode_application_signed(self):
        result = encode_application_signed(-5)
        tag, offset = decode_tag(result, 0)
        assert tag.number == 3
        assert tag.cls == TagClass.APPLICATION
        payload = result[offset : offset + tag.length]
        assert decode_signed(payload) == -5

    def test_encode_application_real(self):
        result = encode_application_real(3.14)
        tag, offset = decode_tag(result, 0)
        assert tag.number == 4
        assert tag.cls == TagClass.APPLICATION
        assert tag.length == 4
        payload = result[offset : offset + tag.length]
        assert decode_real(payload) == pytest.approx(3.14, rel=1e-6)

    def test_encode_application_double(self):
        result = encode_application_double(3.14)
        tag, _offset = decode_tag(result, 0)
        assert tag.number == 5
        assert tag.cls == TagClass.APPLICATION
        assert tag.length == 8

    def test_encode_application_octet_string(self):
        result = encode_application_octet_string(b"\x01\x02")
        tag, offset = decode_tag(result, 0)
        assert tag.number == 6
        assert tag.cls == TagClass.APPLICATION
        assert result[offset:] == b"\x01\x02"

    def test_encode_application_character_string(self):
        result = encode_application_character_string("hi")
        tag, offset = decode_tag(result, 0)
        assert tag.number == 7
        assert tag.cls == TagClass.APPLICATION
        payload = result[offset : offset + tag.length]
        assert decode_character_string(payload) == "hi"

    def test_encode_application_enumerated(self):
        result = encode_application_enumerated(3)
        tag, offset = decode_tag(result, 0)
        assert tag.number == 9
        assert tag.cls == TagClass.APPLICATION
        payload = result[offset : offset + tag.length]
        assert decode_enumerated(payload) == 3

    def test_encode_application_date(self):
        date = BACnetDate(2024, 1, 1, 1)
        result = encode_application_date(date)
        tag, _offset = decode_tag(result, 0)
        assert tag.number == 10
        assert tag.cls == TagClass.APPLICATION
        assert tag.length == 4

    def test_encode_application_time(self):
        t = BACnetTime(12, 0, 0, 0)
        result = encode_application_time(t)
        tag, _offset = decode_tag(result, 0)
        assert tag.number == 11
        assert tag.cls == TagClass.APPLICATION
        assert tag.length == 4

    def test_encode_application_object_id(self):
        result = encode_application_object_id(8, 100)
        tag, offset = decode_tag(result, 0)
        assert tag.number == 12
        assert tag.cls == TagClass.APPLICATION
        assert tag.length == 4
        payload = result[offset : offset + tag.length]
        obj_type, instance = decode_object_identifier(payload)
        assert obj_type == 8
        assert instance == 100

    def test_encode_application_bit_string(self):
        bs = BitString(b"\xf0", 4)
        result = encode_application_bit_string(bs)
        tag, _offset = decode_tag(result, 0)
        assert tag.number == 8
        assert tag.cls == TagClass.APPLICATION


# ---------------------------------------------------------------------------
# Coverage gap tests
# ---------------------------------------------------------------------------


class TestBitStringDataTooShort:
    """Lines 306-307: decode_bit_string raises on empty data."""

    def test_empty_data_raises(self):
        with pytest.raises(ValueError, match="BitString data too short"):
            decode_bit_string(b"")

    def test_memoryview_empty_raises(self):
        with pytest.raises(ValueError, match="BitString data too short"):
            decode_bit_string(memoryview(b""))


class TestDateYearOutOfRange:
    """Lines 327-328: encode_date raises when year is out of range."""

    def test_year_too_low(self):
        date = BACnetDate(year=1899, month=1, day=1, day_of_week=1)
        with pytest.raises(ValueError, match="BACnetDate year must be 1900-2155"):
            encode_date(date)

    def test_year_too_high(self):
        date = BACnetDate(year=2156, month=1, day=1, day_of_week=1)
        with pytest.raises(ValueError, match="BACnetDate year must be 1900-2155"):
            encode_date(date)

    def test_year_zero_raises(self):
        date = BACnetDate(year=0, month=1, day=1, day_of_week=1)
        with pytest.raises(ValueError, match="BACnetDate year must be 1900-2155"):
            encode_date(date)


class TestDateDataTooShort:
    """Lines 342-343: decode_date raises when data is < 4 bytes."""

    def test_empty_data(self):
        with pytest.raises(ValueError, match="Date data too short"):
            decode_date(b"")

    def test_three_bytes(self):
        with pytest.raises(ValueError, match="Date data too short"):
            decode_date(b"\x7c\x07\x0f")

    def test_one_byte(self):
        with pytest.raises(ValueError, match="Date data too short"):
            decode_date(b"\x7c")


class TestTimeDataTooShort:
    """Lines 368-369: decode_time raises when data is < 4 bytes."""

    def test_empty_data(self):
        with pytest.raises(ValueError, match="Time data too short"):
            decode_time(b"")

    def test_three_bytes(self):
        with pytest.raises(ValueError, match="Time data too short"):
            decode_time(b"\x0e\x1e\x2d")

    def test_one_byte(self):
        with pytest.raises(ValueError, match="Time data too short"):
            decode_time(b"\x0e")


class TestUnknownApplicationTag:
    """Lines 750-752: decode_application_value with unknown tag number."""

    def test_tag_number_13_raises(self):
        from bac_py.encoding.primitives import decode_application_value

        # Application tag 13, class=application(0), length=1
        # Byte: (13 << 4) | (0 << 3) | 1 = 0xD1
        data = bytes([0xD1, 0x00])
        with pytest.raises(ValueError, match="Unknown application tag number"):
            decode_application_value(data)

    def test_tag_number_14_raises(self):
        from bac_py.encoding.primitives import decode_application_value

        # Application tag 14, class=application(0), length=1
        # Byte: (14 << 4) | (0 << 3) | 1 = 0xE1
        data = bytes([0xE1, 0x00])
        with pytest.raises(ValueError, match="Unknown application tag number"):
            decode_application_value(data)


class TestUnsupportedValueType:
    """Line 995: encode_property_value raises for unsupported type."""

    def test_unsupported_type_raises(self):
        from bac_py.encoding.primitives import encode_property_value

        with pytest.raises(TypeError, match="Cannot encode value of type"):
            encode_property_value(object())


# ---------------------------------------------------------------------------
# Security: decoded values limit
# ---------------------------------------------------------------------------


class TestDecodedValuesLimit:
    """Crafted payloads with many tiny elements should be capped."""

    def test_exceeds_limit_raises(self):
        from bac_py.encoding.primitives import _MAX_DECODED_VALUES

        # Each application-tagged Null is 1 byte (0x00)
        data = b"\x00" * (_MAX_DECODED_VALUES + 1)
        with pytest.raises(ValueError, match="Decoded value count exceeds maximum"):
            decode_all_application_values(data)

    def test_within_limit_succeeds(self):
        # 100 Nulls — well within limit
        data = b"\x00" * 100
        result = decode_all_application_values(data)
        assert len(result) == 100
        assert all(v is None for v in result)


# ---------------------------------------------------------------------------
# Optimization: pre-computed lookup tables
# ---------------------------------------------------------------------------


class TestUnsigned1ByteLookupTable:
    """Verify _UNSIGNED_1BYTE lookup table matches to_bytes for 0-255."""

    def test_all_256_values_match(self):
        from bac_py.encoding.primitives import _UNSIGNED_1BYTE

        for i in range(256):
            assert _UNSIGNED_1BYTE[i] == i.to_bytes(1, "big")

    def test_lookup_table_length(self):
        from bac_py.encoding.primitives import _UNSIGNED_1BYTE

        assert len(_UNSIGNED_1BYTE) == 256

    def test_encode_unsigned_uses_fast_path(self):
        """encode_unsigned(0..255) should return the pre-computed object."""
        from bac_py.encoding.primitives import _UNSIGNED_1BYTE

        for i in (0, 1, 127, 255):
            assert encode_unsigned(i) is _UNSIGNED_1BYTE[i]

    def test_encode_unsigned64_uses_fast_path(self):
        """encode_unsigned64(0..255) should return the pre-computed object."""
        from bac_py.encoding.primitives import _UNSIGNED_1BYTE, encode_unsigned64

        for i in (0, 1, 127, 255):
            assert encode_unsigned64(i) is _UNSIGNED_1BYTE[i]


class TestBooleanConstants:
    """Verify encode_boolean uses pre-computed constants."""

    def test_true_returns_constant(self):
        from bac_py.encoding.primitives import _BOOL_TRUE

        assert encode_boolean(True) is _BOOL_TRUE

    def test_false_returns_constant(self):
        from bac_py.encoding.primitives import _BOOL_FALSE

        assert encode_boolean(False) is _BOOL_FALSE

    def test_constant_values(self):
        from bac_py.encoding.primitives import _BOOL_FALSE, _BOOL_TRUE

        assert _BOOL_TRUE == b"\x01"
        assert _BOOL_FALSE == b"\x00"
