"""Comprehensive tests for BGP path attributes framework.

The Attributes class orchestrates parsing of all path attributes in UPDATE messages.
It handles flag validation, length parsing, duplicate detection, and error recovery.

Attribute Header Format:
- Flags (1 byte): Optional, Transitive, Partial, Extended Length
- Type Code (1 byte)
- Length (1 or 2 bytes depending on Extended flag)
- Value (variable)

Target: src/exabgp/bgp/message/update/attribute/attributes.py

Test Coverage:
1. Flag validation (OPTIONAL, TRANSITIVE, PARTIAL, EXTENDED_LENGTH)
2. Length validation (regular 1-byte vs extended 2-byte length)
3. Multiple attribute parsing
4. Duplicate attribute detection
5. Unknown transitive vs non-transitive attributes
6. Zero-length attribute handling
7. Truncated attribute data
8. TREAT_AS_WITHDRAW behavior
9. DISCARD behavior
"""

import struct
from typing import Any, Generator
from unittest.mock import Mock, patch

import pytest


# Mock logger at module level to avoid initialization issues
@pytest.fixture(autouse=True)
def mock_logger() -> Generator[None, None, None]:
    """Mock the logger to avoid initialization issues."""
    with patch('exabgp.bgp.message.update.attribute.attributes.log') as mock_log:
        mock_log.debug = Mock()
        yield


def create_negotiated_mock(asn4: Any = False) -> Any:
    """Create minimal mock negotiated object for testing."""
    negotiated = Mock()
    negotiated.asn4 = asn4
    negotiated.addpath = Mock()
    negotiated.addpath.receive = Mock(return_value=False)
    negotiated.families = []
    return negotiated


def create_attribute_header(flag: Any, type_code: Any, length: Any, extended: Any = False) -> Any:
    """Create attribute header bytes.

    Args:
        flag: Attribute flags byte
        type_code: Attribute type code (1-255)
        length: Length of attribute value
        extended: If True, use 2-byte length (flag bit 4 should be set)

    Returns:
        bytes: Attribute header
    """
    header = bytes([flag, type_code])

    if extended or length > 255:
        # Extended length: 2 bytes
        header += struct.pack('!H', length)
    else:
        # Regular length: 1 byte
        header += bytes([length])

    return header


# =============================================================================
# Test Flag Parsing
# =============================================================================


def test_attributes_parse_origin_transitive() -> None:
    """Test parsing ORIGIN attribute (well-known mandatory transitive)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.origin import Origin
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # ORIGIN attribute: flag=0x40 (TRANSITIVE), type=1, length=1, value=0 (IGP)
    data = bytes([0x40, 0x01, 0x01, 0x00])

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    assert 1 in attributes  # ORIGIN code
    assert isinstance(attributes[1], Origin)
    assert attributes[1].origin == Origin.IGP


def test_attributes_parse_optional_attribute() -> None:
    """Test parsing optional attribute (MED)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.med import MED
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # MED attribute: flag=0x80 (OPTIONAL), type=4, length=4, value=100
    data = bytes([0x80, 0x04, 0x04]) + struct.pack('!I', 100)

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    assert 4 in attributes  # MED code
    assert isinstance(attributes[4], MED)
    assert attributes[4].med == 100


def test_attributes_parse_extended_length() -> None:
    """Test parsing attribute with extended length (2 bytes)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # Create attribute with extended length flag
    # Flag: 0x50 (TRANSITIVE + EXTENDED_LENGTH), type=1 (ORIGIN), length=1, value=0
    flag = 0x40 | 0x10  # TRANSITIVE | EXTENDED_LENGTH
    data = bytes([flag, 0x01]) + struct.pack('!H', 1) + bytes([0x00])

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    assert 1 in attributes  # ORIGIN


# =============================================================================
# Test Multiple Attributes
# =============================================================================


def test_attributes_parse_multiple_attributes() -> None:
    """Test parsing UPDATE with multiple attributes."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.origin import Origin
    from exabgp.bgp.message.update.attribute.med import MED
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # ORIGIN + MED
    origin = bytes([0x40, 0x01, 0x01, 0x00])  # ORIGIN=IGP
    med = bytes([0x80, 0x04, 0x04]) + struct.pack('!I', 100)  # MED=100

    data = origin + med

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    assert 1 in attributes  # ORIGIN
    assert 4 in attributes  # MED
    assert isinstance(attributes[1], Origin)
    assert isinstance(attributes[4], MED)


def test_attributes_parse_as_path() -> None:
    """Test parsing AS_PATH attribute."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.aspath import ASPath
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock(asn4=False)

    # AS_PATH: flag=0x40, type=2, AS_SEQUENCE with 2 ASNs
    as_path_data = struct.pack('!BB', 2, 2) + struct.pack('!HH', 65001, 65002)
    data = bytes([0x40, 0x02, len(as_path_data)]) + as_path_data

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    assert 2 in attributes  # AS_PATH code
    assert isinstance(attributes[2], ASPath)


# =============================================================================
# Test Duplicate Attributes
# =============================================================================


def test_attributes_duplicate_attribute_ignored() -> None:
    """Test that duplicate attributes are ignored (for most attributes)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # Two ORIGIN attributes (duplicate should be ignored)
    origin1 = bytes([0x40, 0x01, 0x01, 0x00])  # ORIGIN=IGP
    origin2 = bytes([0x40, 0x01, 0x01, 0x01])  # ORIGIN=EGP (duplicate)

    data = origin1 + origin2

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should only have one ORIGIN attribute (first one)
    assert 1 in attributes
    assert attributes[1].origin == 0  # IGP from first attribute


def test_attributes_duplicate_attribute_handling() -> None:
    """Test duplicate attribute handling.

    Note: The actual behavior depends on the attribute type.
    MP_REACH_NLRI and MP_UNREACH_NLRI should raise Notify if duplicated,
    but other attributes may be silently ignored.
    """
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # Test with duplicate ORIGIN (should be ignored, first one wins)
    origin1 = bytes([0x40, 0x01, 0x01, 0x00])  # IGP
    origin2 = bytes([0x40, 0x01, 0x01, 0x01])  # EGP

    data = origin1 + origin2

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should only have first ORIGIN
    assert 1 in attributes
    assert attributes[1].origin == 0  # IGP


# =============================================================================
# Test Zero-Length Attributes
# =============================================================================


def test_attributes_zero_length_atomic_aggregate_valid() -> None:
    """Test that ATOMIC_AGGREGATE can have zero length."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.atomicaggregate import AtomicAggregate
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # ATOMIC_AGGREGATE: flag=0x40, type=6, length=0
    data = bytes([0x40, 0x06, 0x00])

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    assert 6 in attributes  # ATOMIC_AGGREGATE code
    assert isinstance(attributes[6], AtomicAggregate)


def test_attributes_zero_length_as_path_valid() -> None:
    """Test that AS_PATH can have zero length (empty path)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # AS_PATH: flag=0x40, type=2, length=0 (empty path)
    data = bytes([0x40, 0x02, 0x00])

    Attributes.unpack(data, Direction.IN, negotiated)

    # Empty AS_PATH returns None, so attribute should not be added
    # or should be added as empty
    # Based on code review, zero-length AS_PATH is valid
    # The attribute may or may not be present depending on implementation
    assert True  # If we get here without exception, test passes


@pytest.mark.parametrize('attr_type', [1, 3, 4, 5])  # ORIGIN, NEXT_HOP, MED, LOCAL_PREF
def test_attributes_zero_length_invalid_treat_as_withdraw(attr_type: Any) -> None:
    """Test that zero-length for certain attributes triggers TREAT_AS_WITHDRAW."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # Create attribute with zero length
    flag = 0x40 if attr_type in [1, 2, 3] else 0x80  # Well-known vs optional
    data = bytes([flag, attr_type, 0x00])  # Zero length

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should have TREAT_AS_WITHDRAW marker
    from exabgp.bgp.message.update.attribute.attribute import Attribute

    assert Attribute.CODE.INTERNAL_TREAT_AS_WITHDRAW in attributes


# =============================================================================
# Test Truncated Attributes
# =============================================================================


def test_attributes_truncated_header() -> None:
    """Test truncated attribute header (only flag byte)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # Only flag byte, no type code or length
    data = bytes([0x40])

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should trigger TREAT_AS_WITHDRAW due to IndexError
    from exabgp.bgp.message.update.attribute.attribute import Attribute

    assert Attribute.CODE.INTERNAL_TREAT_AS_WITHDRAW in attributes


def test_attributes_truncated_length() -> None:
    """Test truncated attribute header (missing length byte)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # Flag + type but no length
    data = bytes([0x40, 0x01])

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should trigger TREAT_AS_WITHDRAW
    from exabgp.bgp.message.update.attribute.attribute import Attribute

    assert Attribute.CODE.INTERNAL_TREAT_AS_WITHDRAW in attributes


def test_attributes_truncated_value() -> None:
    """Test truncated attribute value."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # ORIGIN claims length=1 but no value data
    data = bytes([0x40, 0x01, 0x01])

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should handle gracefully (empty value becomes treat-as-withdraw or parse error)
    from exabgp.bgp.message.update.attribute.attribute import Attribute

    # Either TREAT_AS_WITHDRAW or attribute is not present
    assert Attribute.CODE.INTERNAL_TREAT_AS_WITHDRAW in attributes or 1 not in attributes


# =============================================================================
# Test Unknown Attributes
# =============================================================================


def test_attributes_unknown_transitive() -> None:
    """Test unknown transitive attribute (should be preserved)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.generic import GenericAttribute
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # Unknown attribute type 200 with TRANSITIVE flag
    flag = 0x40 | 0x80  # TRANSITIVE | OPTIONAL
    type_code = 200
    value = b'\x01\x02\x03\x04'
    data = bytes([flag, type_code, len(value)]) + value

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should be preserved as GenericAttribute with PARTIAL flag added
    assert type_code in attributes
    assert isinstance(attributes[type_code], GenericAttribute)


def test_attributes_unknown_non_transitive() -> None:
    """Test unknown non-transitive attribute (should be ignored)."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # Unknown attribute type 201 without TRANSITIVE flag
    flag = 0x80  # OPTIONAL, not TRANSITIVE
    type_code = 201
    value = b'\x01\x02\x03\x04'
    data = bytes([flag, type_code, len(value)]) + value

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should be ignored (not in attributes)
    assert type_code not in attributes


# =============================================================================
# Test Flag Validation
# =============================================================================


def test_attributes_invalid_flag_for_known_attribute_treat_as_withdraw() -> None:
    """Test that invalid flags for TREAT_AS_WITHDRAW attributes trigger withdrawal."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # ORIGIN with wrong flag (should be 0x40 TRANSITIVE, we use 0x00)
    data = bytes([0x00, 0x01, 0x01, 0x00])

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should trigger TREAT_AS_WITHDRAW
    from exabgp.bgp.message.update.attribute.attribute import Attribute

    assert Attribute.CODE.INTERNAL_TREAT_AS_WITHDRAW in attributes


def test_attributes_invalid_flag_for_discard_attribute() -> None:
    """Test that invalid flags for DISCARD attributes are discarded."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    # ATOMIC_AGGREGATE (type 6) with wrong flags
    # Should have TRANSITIVE (0x40), we give it OPTIONAL only (0x80)
    data = bytes([0x80, 0x06, 0x00])

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Attribute should be discarded (not present)
    assert 6 not in attributes


# =============================================================================
# Test AS_PATH + AS4_PATH Merging
# =============================================================================


def test_attributes_as4_path_alone() -> None:
    """Test that AS4_PATH can be parsed independently."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.aspath import AS4Path
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock(asn4=False)

    # AS4_PATH with real ASN (100000) - without AS_PATH present
    as4_path_data = struct.pack('!BB', 2, 1) + struct.pack('!L', 100000)
    data = bytes([0xC0, 17, len(as4_path_data)]) + as4_path_data  # type 17 = AS4_PATH

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # AS4_PATH should be present
    assert 17 in attributes
    assert isinstance(attributes[17], AS4Path)


# =============================================================================
# Test Empty Attributes
# =============================================================================


def test_attributes_empty_data() -> None:
    """Test parsing empty attributes data."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.direction import Direction

    negotiated = create_negotiated_mock()

    data = b''

    attributes = Attributes.unpack(data, Direction.IN, negotiated)

    # Should return empty attributes dict
    assert len(attributes) == 0


# =============================================================================
# Test Attributes Methods
# =============================================================================


def test_attributes_has_method() -> None:
    """Test Attributes.has() method."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.origin import Origin

    attributes = Attributes()
    attributes.add(Origin(Origin.IGP))

    assert attributes.has(1)  # ORIGIN
    assert not attributes.has(2)  # AS_PATH


def test_attributes_remove_method() -> None:
    """Test Attributes.remove() method."""
    from exabgp.bgp.message.update.attribute.attributes import Attributes
    from exabgp.bgp.message.update.attribute.origin import Origin

    attributes = Attributes()
    attributes.add(Origin(Origin.IGP))

    assert attributes.has(1)

    attributes.remove(1)

    assert not attributes.has(1)


if __name__ == '__main__':
    pytest.main([__file__, '-v'])
