"""
test_crsf_frames.py — Unit tests for CRSF telemetry frame building (Issue #103).

Mirrors the C logic in src/crsf.c for crsf_send_battery() and
crsf_send_flight_mode() so frame layout and CRC can be verified
without flashing hardware.

Run with:  pytest test/test_crsf_frames.py -v
"""

import struct
import pytest


# ── CRC8 DVB-S2 (polynomial 0xD5) ─────────────────────────────────────────

def crc8_dvb_s2(crc: int, byte: int) -> int:
    crc ^= byte
    for _ in range(8):
        crc = ((crc << 1) ^ 0xD5) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF
    return crc


def crsf_frame_crc(frame: bytes) -> int:
    """CRC covers frame[2] (type) through frame[-2] (last payload byte)."""
    crc = 0
    for b in frame[2:-1]:
        crc = crc8_dvb_s2(crc, b)
    return crc


# ── Frame builders matching C implementation ───────────────────────────────

CRSF_SYNC = 0xC8


def build_battery_frame(voltage_mv: int, current_ma: int,
                         remaining_pct: int) -> bytes:
    """
    Type 0x08 — battery sensor.
    voltage  : 100 mV units, uint16 big-endian
    current  : 100 mA units, uint16 big-endian
    capacity : 0 mAh (not tracked), uint24 big-endian
    remaining: 0–100 %, uint8
    Total payload = 8 bytes.
    """
    v100 = voltage_mv // 100
    c100 = current_ma // 100
    payload = struct.pack('>HH3sB',
                          v100, c100,
                          b'\x00\x00\x00',
                          remaining_pct)
    frame_type = 0x08
    # SYNC + LEN(TYPE+payload+CRC) + TYPE + payload + CRC
    frame_body = bytes([CRSF_SYNC, len(payload) + 2, frame_type]) + payload
    frame = frame_body + b'\x00'  # placeholder CRC slot
    crc = crsf_frame_crc(frame)
    return frame_body + bytes([crc])


def build_flight_mode_frame(armed: bool) -> bytes:
    """Type 0x21 — flight mode text, null-terminated."""
    text = b'ARMED\x00' if armed else b'DISARM\x00'
    frame_type = 0x21
    frame_body = bytes([CRSF_SYNC, len(text) + 2, frame_type]) + text
    frame = frame_body + b'\x00'
    crc = crsf_frame_crc(frame)
    return frame_body + bytes([crc])


# ── Helpers ────────────────────────────────────────────────────────────────

def validate_frame(frame: bytes):
    """Assert basic CRSF frame invariants."""
    assert frame[0] == CRSF_SYNC, "bad sync byte"
    length_field = frame[1]
    total = length_field + 2        # SYNC + LEN + rest
    assert len(frame) == total,    f"frame length mismatch: {len(frame)} != {total}"
    assert len(frame) <= 64,       "frame too long (CRSF max 64 bytes)"
    expected_crc = crsf_frame_crc(frame)
    assert frame[-1] == expected_crc, \
        f"CRC mismatch: got {frame[-1]:#04x}, expected {expected_crc:#04x}"


# ── Battery frame tests ────────────────────────────────────────────────────

class TestBatteryFrame:
    def test_sync_byte(self):
        f = build_battery_frame(12600, 0, 100)
        assert f[0] == 0xC8

    def test_frame_type(self):
        f = build_battery_frame(12600, 0, 100)
        assert f[2] == 0x08

    def test_frame_invariants(self):
        validate_frame(build_battery_frame(12600, 5000, 80))

    def test_voltage_encoding_3s_full(self):
        """12.6 V → 126 in 100 mV units, big-endian."""
        f = build_battery_frame(12600, 0, 100)
        v100 = (f[3] << 8) | f[4]
        assert v100 == 126

    def test_voltage_encoding_4s_full(self):
        """16.8 V → 168."""
        f = build_battery_frame(16800, 0, 100)
        v100 = (f[3] << 8) | f[4]
        assert v100 == 168

    def test_current_encoding(self):
        """5000 mA → 50 in 100 mA units."""
        f = build_battery_frame(12000, 5000, 75)
        c100 = (f[5] << 8) | f[6]
        assert c100 == 50

    def test_remaining_pct(self):
        f = build_battery_frame(11000, 0, 42)
        assert f[10] == 42

    def test_capacity_zero(self):
        """Capacity bytes (cap_hi, cap_mid, cap_lo) are zero — no coulomb counter."""
        f = build_battery_frame(12600, 0, 100)
        assert f[7] == 0 and f[8] == 0 and f[9] == 0

    def test_crc_correct(self):
        f = build_battery_frame(11500, 2000, 65)
        validate_frame(f)

    def test_zero_voltage(self):
        """Disconnected battery → 0 mV, 0 %."""
        f = build_battery_frame(0, 0, 0)
        validate_frame(f)
        v100 = (f[3] << 8) | f[4]
        assert v100 == 0
        assert f[10] == 0

    def test_total_frame_length(self):
        """Battery frame: SYNC(1)+LEN(1)+TYPE(1)+payload(8)+CRC(1) = 12 bytes."""
        f = build_battery_frame(12000, 0, 80)
        assert len(f) == 12


# ── Flight mode frame tests ────────────────────────────────────────────────

class TestFlightModeFrame:
    def test_armed_text(self):
        f = build_flight_mode_frame(True)
        payload = f[3:-1]
        assert payload == b'ARMED\x00'

    def test_disarmed_text(self):
        f = build_flight_mode_frame(False)
        payload = f[3:-1]
        assert payload == b'DISARM\x00'

    def test_frame_type(self):
        assert build_flight_mode_frame(True)[2] == 0x21
        assert build_flight_mode_frame(False)[2] == 0x21

    def test_crc_armed(self):
        validate_frame(build_flight_mode_frame(True))

    def test_crc_disarmed(self):
        validate_frame(build_flight_mode_frame(False))

    def test_armed_frame_length(self):
        """ARMED\0 = 6 bytes payload → total 10 bytes."""
        f = build_flight_mode_frame(True)
        assert len(f) == 10

    def test_disarmed_frame_length(self):
        """DISARM\0 = 7 bytes payload → total 11 bytes."""
        f = build_flight_mode_frame(False)
        assert len(f) == 11


# ── CRC8 DVB-S2 self-test ─────────────────────────────────────────────────

class TestCRC8:
    def test_known_vector(self):
        """Verify CRC8 DVB-S2 against known value (poly 0xD5, init 0)."""
        # CRC of single byte 0xAB with poly 0xD5, init 0 → 0xC8
        crc = crc8_dvb_s2(0, 0xAB)
        assert crc == 0xC8

    def test_different_payloads_differ(self):
        f1 = build_battery_frame(12600, 0, 100)
        f2 = build_battery_frame(11000, 0, 50)
        assert f1[-1] != f2[-1], "different payloads should have different CRCs"

    def test_crc_covers_type(self):
        """Changing the frame type changes the CRC."""
        fa = build_battery_frame(12600, 0, 100)   # type 0x08
        fb = build_flight_mode_frame(True)          # type 0x21
        # Both frames differ in type byte and thus CRC
        assert fa[-1] != fb[-1]


# ── battery_estimate_pct logic mirrored in Python ─────────────────────────

def battery_estimate_pct(voltage_mv: int) -> int:
    """Python mirror of battery_estimate_pct() in battery.c."""
    if voltage_mv >= 13000:
        v_min, v_max = 13200, 16800
    else:
        v_min, v_max = 9900, 12600
    if voltage_mv <= v_min:
        return 0
    if voltage_mv >= v_max:
        return 100
    return int((voltage_mv - v_min) * 100 / (v_max - v_min))


class TestBatteryEstimatePct:
    def test_3s_full(self):
        assert battery_estimate_pct(12600) == 100

    def test_3s_empty(self):
        assert battery_estimate_pct(9900) == 0

    def test_3s_mid(self):
        pct = battery_estimate_pct(11250)
        assert 45 <= pct <= 55

    def test_4s_full(self):
        assert battery_estimate_pct(16800) == 100

    def test_4s_empty(self):
        assert battery_estimate_pct(13200) == 0

    def test_3s_over_voltage(self):
        """13000 mV triggers 4S branch (v_min=13200) → classified as dead 4S → 0%."""
        assert battery_estimate_pct(13000) == 0

    def test_zero_voltage(self):
        assert battery_estimate_pct(0) == 0
