"""Tests for aioacaia.parser covering each message-parsing path."""

import pytest

from aioacaia.exceptions import (
    AcaiaMessageError,
    AcaiaMessageTooLong,
    AcaiaMessageTooShort,
)
from aioacaia.messages import (
    ButtonMessage,
    ButtonType,
    Settings,
    TimerMessage,
    WeightMessage,
)
from aioacaia.parser import decode
from aioacaia.scale import AcaiaScale
from tests.fixtures import messages as m


def _decode_message(raw: bytes):
    """Decode a single, complete message and assert nothing remains."""
    msg, remaining = decode(bytearray(raw))
    assert remaining == b""
    return msg


def _assert_close(actual, expected):
    """Assert equality, tolerating float rounding, and handling None."""
    if expected is None:
        assert actual is None
    else:
        assert actual == pytest.approx(expected)


@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        (m.WEIGHT, 175.9),
        (m.WEIGHT_NEGATIVE, -175.9),
        (m.WEIGHT_UNIT_100, 17.59),
        (m.WEIGHT_UNIT_1000, 1.759),
        (m.WEIGHT_UNIT_10000, 0.1759),
    ],
)
def test_decode_weight(raw, expected):
    """Weight messages decode to the expected gram value."""
    msg = _decode_message(raw)
    assert isinstance(msg, WeightMessage)
    assert msg.weight == pytest.approx(expected)


def test_decode_weight_invalid_unit_raises():
    """An out-of-range unit byte raises a message error."""
    with pytest.raises(AcaiaMessageError, match="Unknown weight unit"):
        decode(bytearray(m.WEIGHT_BAD_UNIT))


def test_decode_timer():
    """Timer messages decode to seconds."""
    msg = _decode_message(m.TIMER)
    assert isinstance(msg, TimerMessage)
    assert msg.time == pytest.approx(90.5)


def test_decode_heartbeat_weight():
    """A heartbeat wrapping a weight decodes to a WeightMessage."""
    msg = _decode_message(m.HEARTBEAT_WEIGHT)
    assert isinstance(msg, WeightMessage)
    assert msg.weight == pytest.approx(175.9)


def test_decode_heartbeat_time():
    """A heartbeat wrapping a timer decodes to a TimerMessage."""
    msg = _decode_message(m.HEARTBEAT_TIME)
    assert isinstance(msg, TimerMessage)
    assert msg.time == pytest.approx(90.5)


def test_decode_heartbeat_uses_first_record():
    """A heartbeat is decoded by its first wrapped record, not a later one."""
    msg = _decode_message(m.HEARTBEAT_WEIGHT_THEN_TIME)
    assert isinstance(msg, WeightMessage)
    assert msg.weight == pytest.approx(175.9)


@pytest.mark.parametrize(
    ("raw", "button", "timer_running", "expected_time", "expected_weight"),
    [
        (m.BUTTON_TARE, ButtonType.TARE, None, None, 175.9),
        (m.BUTTON_START_WEIGHT, ButtonType.START, True, None, 175.9),
        (m.BUTTON_START, ButtonType.START, True, None, None),
        (m.BUTTON_STOP_TIME_WEIGHT, ButtonType.STOP, False, 90.5, 175.9),
        (m.BUTTON_STOP_TIME, ButtonType.STOP, False, 90.5, None),
        (m.BUTTON_STOP, ButtonType.STOP, False, None, None),
        (m.BUTTON_RESET_TIME_WEIGHT, ButtonType.RESET, None, 90.5, 175.9),
        (m.BUTTON_RESET_TIME, ButtonType.RESET, None, 90.5, None),
        (m.BUTTON_RESET, ButtonType.RESET, None, None, None),
        (m.BUTTON_STOP_WEIGHT, ButtonType.STOP, False, None, 175.9),
        (m.BUTTON_RESET_WEIGHT, ButtonType.RESET, None, None, 175.9),
        (m.BUTTON_UNKNOWN, ButtonType.UNKNOWN, None, None, None),
    ],
)
def test_decode_button(raw, button, timer_running, expected_time, expected_weight):
    """Button messages decode to the right event, timer state, time and weight."""
    msg = _decode_message(raw)
    assert isinstance(msg, ButtonMessage)
    assert msg.button is button
    assert msg.timer_running is timer_running
    _assert_close(msg.time, expected_time)
    _assert_close(msg.weight, expected_weight)


def test_decode_unknown_type_raises():
    """An unknown message type raises AcaiaMessageError."""
    with pytest.raises(AcaiaMessageError):
        decode(bytearray(m.UNKNOWN_TYPE))


def test_decode_invalid_checksum_raises():
    """A frame with a mismatched checksum is rejected."""
    with pytest.raises(AcaiaMessageError, match="checksum"):
        decode(bytearray(m.BAD_CHECKSUM))


def test_decode_short_weight_payload_raises_message_error():
    """A valid frame with an undersized weight payload is rejected cleanly."""
    with pytest.raises(AcaiaMessageError, match="Weight payload is too short"):
        decode(bytearray(m.SHORT_WEIGHT))


@pytest.mark.parametrize(
    ("raw", "units"),
    [
        (m.SETTINGS_GRAMS, "grams"),
        (m.SETTINGS_OUNCES, "ounces"),
    ],
)
def test_decode_settings(raw, units):
    """Settings messages decode battery, units, auto-off and beep."""
    settings, remaining = decode(bytearray(raw))
    assert isinstance(settings, Settings)
    assert remaining == b""
    assert settings.units == units
    assert settings.battery == 93
    assert settings.auto_off == 5
    assert settings.beep_on is True


def test_decode_too_short_raises():
    """A header-only message raises AcaiaMessageTooShort with the received bytes."""
    with pytest.raises(AcaiaMessageTooShort) as exc:
        decode(bytearray(m.HEADER_ONLY))
    assert exc.value.bytes_recvd == bytearray(m.HEADER_ONLY)


def test_decode_too_long_raises():
    """A length byte past the buffer raises AcaiaMessageTooLong."""
    with pytest.raises(AcaiaMessageTooLong):
        decode(bytearray(m.TOO_LONG))


def test_decode_unknown_command_returns_none():
    """An unknown command yields no message but consumes the bytes."""
    msg, remaining = decode(bytearray(m.UNKNOWN_COMMAND))
    assert msg is None
    assert remaining == b""


def test_decode_skips_leading_garbage():
    """Bytes before the header are ignored."""
    msg = _decode_message(m.LEADING_GARBAGE)
    assert isinstance(msg, WeightMessage)
    assert msg.weight == pytest.approx(175.9)


def test_decode_returns_remaining_bytes():
    """Trailing bytes after a message are returned for the next decode."""
    first, remaining = decode(bytearray(m.TWO_MESSAGES))
    assert isinstance(first, WeightMessage)
    assert remaining == bytearray(m.SETTINGS_GRAMS)
    second, tail = decode(remaining)
    assert isinstance(second, Settings)
    assert tail == b""


def test_decode_split_message_reassembly():
    """A message split across two chunks decodes once reassembled."""
    with pytest.raises(AcaiaMessageTooShort) as exc:
        decode(bytearray(m.HEADER_ONLY))
    assert exc.value.bytes_recvd == bytearray(m.HEADER_ONLY)

    msg, remaining = decode(bytearray(m.HEADER_ONLY + m.SPLIT_REMAINDER))
    assert isinstance(msg, WeightMessage)
    assert msg.weight == pytest.approx(175.9)
    assert remaining == b""


async def test_scale_reassembles_split_notifications():
    """The scale buffers a header-only chunk and decodes it with the next."""
    scale = AcaiaScale("aa:bb:cc:dd:ee:ff")

    await scale.on_bluetooth_data_received(None, bytearray(m.HEADER_ONLY))
    assert scale.weight is None

    await scale.on_bluetooth_data_received(None, bytearray(m.SPLIT_REMAINDER))
    assert scale.weight == pytest.approx(175.9)


@pytest.mark.parametrize("split_at", range(1, len(m.WEIGHT)))
async def test_scale_reassembles_notifications_split_at_any_byte(split_at):
    """The scale retains partial headers and partial message bodies."""
    scale = AcaiaScale("aa:bb:cc:dd:ee:ff")

    await scale.on_bluetooth_data_received(None, bytearray(m.WEIGHT[:split_at]))
    assert scale.weight is None

    await scale.on_bluetooth_data_received(None, bytearray(m.WEIGHT[split_at:]))
    assert scale.weight == pytest.approx(175.9)


async def test_scale_processes_all_messages_in_one_notification():
    """Coalesced frames are all applied instead of dropping the trailing frame."""
    scale = AcaiaScale("aa:bb:cc:dd:ee:ff")

    await scale.on_bluetooth_data_received(None, bytearray(m.TWO_MESSAGES))

    assert scale.weight == pytest.approx(175.9)
    assert scale.device_state is not None
    assert scale.device_state.battery_level == 93


async def test_scale_recovers_from_incomplete_frame_before_valid_frame():
    """A corrupt length does not pin valid data behind the incomplete frame."""
    scale = AcaiaScale("aa:bb:cc:dd:ee:ff")

    await scale.on_bluetooth_data_received(None, bytearray(m.TOO_LONG + m.WEIGHT))

    assert scale.weight == pytest.approx(175.9)
    assert scale._last_short_msg is None


async def test_scale_does_not_resync_to_header_inside_fragmented_payload():
    """Header bytes inside an incomplete payload remain part of that frame."""
    scale = AcaiaScale("aa:bb:cc:dd:ee:ff")
    raw = m.WEIGHT_WITH_EMBEDDED_HEADER

    await scale.on_bluetooth_data_received(None, bytearray(raw[:7]))
    await scale.on_bluetooth_data_received(None, bytearray(raw[7:]))

    assert scale.weight == pytest.approx(5681.5)


@pytest.mark.parametrize(
    ("raw", "button", "timer_running", "expected_time", "expected_weight"),
    [
        # --- Original Pearl (PEARL-244684) ---
        # A stop reporting both weight and the elapsed time behind it. Decoding
        # the weight record as a time turned 55.1 g into "2342 s" — plausible
        # enough to go unnoticed, and no exception to catch it.
        (m.REAL_STOP_WEIGHT_TIME, ButtonType.STOP, False, 9.7, 55.1),
        # Presses arriving behind a 0b record, which the scale does send.
        (m.REAL_START_BEHIND_TAG_0B, ButtonType.START, True, None, 54.9),
        (m.REAL_RESET_BEHIND_TAG_0B, ButtonType.RESET, None, None, 0.0),
        # --- 2021+ new-style scale ---
        # Start and reset are a bare key code (no records, no tag 0b); stop
        # carries a weight then a time record. Stop time cross-checked against
        # the wall clock (10.8 s decoded vs 10.6 s measured).
        (m.REAL_2021_START, ButtonType.START, True, None, None),
        (m.REAL_2021_STOP_WEIGHT_TIME, ButtonType.STOP, False, 10.8, 0.0),
        (m.REAL_2021_RESET, ButtonType.RESET, None, None, None),
    ],
)
def test_decode_button_real_captures(
    raw, button, timer_running, expected_time, expected_weight
):
    """Frames captured verbatim from real scales (Pearl and 2021+) decode correctly."""
    msg = _decode_message(raw)
    assert isinstance(msg, ButtonMessage)
    assert msg.button is button
    assert msg.timer_running is timer_running
    _assert_close(msg.time, expected_time)
    _assert_close(msg.weight, expected_weight)


def test_decode_heartbeat_wrapped_button():
    """A button nested in a heartbeat is a press, not an undecodable payload."""
    msg = _decode_message(m.REAL_HEARTBEAT_WRAPPED_START)
    assert isinstance(msg, ButtonMessage)
    assert msg.button is ButtonType.START
    assert msg.timer_running is True
