//! Targeted tests that exercise specific branches of the codec so
//! surviving mutants surface quickly.
//!
//! These complement `vectors.rs` (canonical wire bytes) and
//! `proptest.rs` (random round-trips) by pinning narrow edge cases that
//! random strategies tend to miss — e.g. off-by-one comparisons, CRC
//! byte order, COBS boundary cases, reserved-bit validation.

#![allow(clippy::unwrap_used, clippy::panic, clippy::bool_assert_comparison)]

use donglora_protocol::{
    Command, CommandEncodeError, CommandParseError, DeviceMessage, DeviceMessageParseError,
    ErrorCode, FrameDecodeError, FrameDecoder, FrameResult, Info, InfoParseError, LoRaBandwidth,
    LoRaCodingRate, LoRaConfig, LoRaHeaderMode, MAX_MCU_UID_LEN, MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD,
    MAX_PAYLOAD_FIELD, MAX_PRE_COBS_FRAME, MAX_RADIO_UID_LEN, MAX_SETCONFIG_OK_PAYLOAD,
    MAX_WIRE_FRAME, Modulation, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult,
    SetConfigResultCode, TxDonePayload, TxFlags, TxResult, cap, commands, crc::crc16, encode_frame,
    events,
};
use heapless::Vec as HVec;

// ── CRC boundary vectors ───────────────────────────────────────────

#[test]
fn crc16_check_value_is_0x29b1() {
    // Spec Appendix B — the canonical CRC-16/CCITT-FALSE check value.
    // Any accidental variant switch (KERMIT, XMODEM, reflected, different
    // init) breaks this before it reaches the wire layer.
    assert_eq!(crc16(b"123456789"), 0x29B1);
}

#[test]
fn crc16_single_null_byte_not_zero() {
    // A naive "init = 0" implementation would yield 0 for a single null
    // input. The correct CCITT-FALSE init 0xFFFF yields 0xE1F0.
    assert_eq!(crc16(&[0x00]), 0xE1F0);
}

#[test]
fn crc16_empty_input_is_initial_value() {
    assert_eq!(crc16(&[]), 0xFFFF);
}

// ── Frame CRC trailer positioning ──────────────────────────────────

#[test]
fn encode_then_decode_flipped_crc_byte_fails() {
    // Target: swapping CRC byte order or indexing off-by-one in the
    // CRC trailer placement. Take a valid frame, flip one CRC byte, and
    // confirm the decoder rejects.
    let mut wire = [0u8; MAX_WIRE_FRAME];
    let n = encode_frame(commands::TYPE_PING, 0x0042, &[], &mut wire).unwrap();
    // Last byte is 0x00 sentinel; the byte before that is part of the
    // COBS-encoded CRC. Flip it to inject CRC corruption.
    wire[n - 2] ^= 0x01;

    let mut decoder = FrameDecoder::new();
    let mut saw_err = false;
    decoder.feed(&wire[..n], |res| {
        if matches!(res, FrameResult::Err(_)) {
            saw_err = true;
        }
    });
    assert!(saw_err, "CRC-corrupted frame must produce FrameResult::Err");
}

#[test]
fn decoder_rejects_frame_exactly_4_bytes_post_cobs() {
    // Min valid post-COBS frame is 5 bytes (type + tag + crc). A frame
    // that decodes to exactly 4 bytes post-COBS must be flagged TooShort
    // — catches off-by-one on the minimum-length check.
    // COBS of [0xAA, 0xBB, 0xCC, 0xDD] is [0x05, 0xAA, 0xBB, 0xCC, 0xDD].
    let wire = [0x05, 0xAA, 0xBB, 0xCC, 0xDD, 0x00];
    let mut decoder = FrameDecoder::new();
    let mut saw_too_short = false;
    decoder.feed(&wire, |res| {
        if matches!(res, FrameResult::Err(FrameDecodeError::TooShort)) {
            saw_too_short = true;
        }
    });
    assert!(saw_too_short);
}

#[test]
fn decoder_accepts_frame_exactly_5_bytes_post_cobs() {
    // The 5-byte boundary must succeed (empty payload: type + tag + crc).
    let mut wire = [0u8; MAX_WIRE_FRAME];
    let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
    let mut decoder = FrameDecoder::new();
    let mut saw_ok = false;
    decoder.feed(&wire[..n], |res| {
        if matches!(res, FrameResult::Ok { .. }) {
            saw_ok = true;
        }
    });
    assert!(saw_ok);
}

// ── TX flag boundary ───────────────────────────────────────────────

#[test]
fn tx_flags_reserved_bits_each_rejected() {
    // Target: `b & !0b0000_0001 != 0` mutated to `== 0` or flipped
    // elsewhere. Verify every single reserved bit position triggers
    // rejection.
    for bit in 1..8 {
        let byte = 1u8 << bit;
        assert!(
            matches!(
                TxFlags::from_byte(byte),
                Err(CommandParseError::ReservedBitSet)
            ),
            "bit {bit} must be rejected",
        );
    }
    // Bit 0 alone is allowed (that's the skip_cad flag).
    assert_eq!(TxFlags::from_byte(0b0000_0001).unwrap().skip_cad, true);
}

// ── Empty-TX rejection ─────────────────────────────────────────────

#[test]
fn tx_parse_rejects_only_flags_byte() {
    // Spec §6.4: TX with zero data bytes must be rejected.
    assert!(matches!(
        Command::parse(commands::TYPE_TX, &[0x00]),
        Err(CommandParseError::WrongLength)
    ));
}

#[test]
fn tx_parse_accepts_one_data_byte() {
    // Minimum valid TX: flags byte + exactly one data byte.
    let got = Command::parse(commands::TYPE_TX, &[0x00, 0x42]).unwrap();
    match got {
        Command::Tx { flags, data } => {
            assert_eq!(flags.skip_cad, false);
            assert_eq!(data.as_slice(), &[0x42]);
        }
        other => panic!("expected Tx, got {:?}", other),
    }
}

#[test]
fn tx_parse_rejects_payload_exceeding_max_ota() {
    // Craft MAX_OTA_PAYLOAD + 1 bytes of data (plus the flags byte).
    let mut bytes: HVec<u8, { MAX_OTA_PAYLOAD + 2 }> = HVec::new();
    bytes.push(0x00).unwrap(); // flags
    for _ in 0..=MAX_OTA_PAYLOAD {
        bytes.push(0x55).unwrap();
    }
    assert!(matches!(
        Command::parse(commands::TYPE_TX, &bytes),
        Err(CommandParseError::WrongLength)
    ));
}

// ── COBS run-length boundaries ─────────────────────────────────────

#[test]
fn cobs_254_byte_nonzero_run() {
    // A long run of nonzero payload bytes forces COBS to emit a 0xFF
    // code byte and start a fresh run — the classic boundary case
    // from `PROTOCOL.md §C.8.3`. We pick a run large enough that the
    // type + tag + payload + CRC span exceeds 254 bytes without any
    // interior zero to break it up.
    let mut big: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
    for i in 1..=253u8 {
        big.push(i).unwrap();
    }
    let mut wire = [0u8; MAX_WIRE_FRAME];
    let n = encode_frame(0x04, 0x0101, &big, &mut wire).unwrap();

    let mut decoder = FrameDecoder::new();
    let mut seen: Option<(u8, u16, HVec<u8, MAX_PAYLOAD_FIELD>)> = None;
    decoder.feed(&wire[..n], |res| {
        if let FrameResult::Ok {
            type_id,
            tag,
            payload,
        } = res
        {
            let mut p = HVec::new();
            p.extend_from_slice(payload).unwrap();
            seen = Some((type_id, tag, p));
        }
    });
    let (ty, tag, payload) = seen.unwrap();
    assert_eq!(ty, 0x04);
    assert_eq!(tag, 0x0101);
    assert_eq!(payload.len(), big.len());
    assert_eq!(payload.as_slice(), big.as_slice());
}

#[test]
fn decoder_handles_split_feed() {
    // Feed one full frame across two `feed()` calls. Accumulator must
    // preserve the partial state, not reset on each call.
    let mut wire = [0u8; MAX_WIRE_FRAME];
    let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
    assert!(n >= 3);

    let mut decoder = FrameDecoder::new();
    let mut count = 0;
    decoder.feed(&wire[..n / 2], |_| {
        count += 1;
    });
    assert_eq!(count, 0, "no 0x00 in first chunk → no frame yet");

    decoder.feed(&wire[n / 2..n], |res| {
        if matches!(res, FrameResult::Ok { .. }) {
            count += 1;
        }
    });
    assert_eq!(count, 1, "second chunk contains the 0x00 sentinel");
}

// ── Tag little-endian byte order ───────────────────────────────────

#[test]
fn tag_byte_order_is_le() {
    // tag=0x0102 → bytes 02 01 (LE). Any mutant swapping the tag
    // bytes would fail here.
    let mut wire = [0u8; MAX_WIRE_FRAME];
    let n = encode_frame(0x01, 0x0102, &[], &mut wire).unwrap();

    let mut decoder = FrameDecoder::new();
    let mut seen_tag = None;
    decoder.feed(&wire[..n], |res| {
        if let FrameResult::Ok { tag, .. } = res {
            seen_tag = Some(tag);
        }
    });
    assert_eq!(seen_tag, Some(0x0102));
}

// ── Size-constant pins (lib.rs) ─────────────────────────────────────
//
// The `+` in `MAX_PRE_COBS_FRAME = FRAME_HEADER_SIZE + MAX_PAYLOAD_FIELD +
// FRAME_TRAILER_SIZE` etc. is a prime mutation target. Buffers sized
// from the mutated constant still work (bigger ≠ failure, smaller
// masked by existing slack), so exact-value tests are the canonical
// kill.

#[test]
fn max_pre_cobs_frame_pins_exactly_280() {
    assert_eq!(MAX_PRE_COBS_FRAME, 280);
}

#[test]
fn max_wire_frame_pins_exactly_284() {
    // 280 pre-COBS + 3 worst-case COBS overhead + 1 sentinel.
    assert_eq!(MAX_WIRE_FRAME, 284);
}

#[test]
fn max_ok_payload_pins_exactly_85() {
    // 37 fixed + 32 mcu_uid + 16 radio_uid. Any mutation that flips
    // `+` to `*` or `-` crashes this.
    assert_eq!(MAX_OK_PAYLOAD, 85);
}

#[test]
fn max_setconfig_ok_payload_pins_exactly_27() {
    // 2 (result + owner) + 1 (modulation_id) + 24 (FSK with 8B sync).
    assert_eq!(MAX_SETCONFIG_OK_PAYLOAD, 27);
}

// ── Command::encode_payload / parse boundaries (commands.rs) ────────

fn tx_cmd(data_len: usize) -> Command {
    let mut d: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
    for i in 0..data_len {
        d.push(i as u8).unwrap();
    }
    Command::Tx {
        flags: TxFlags::default(),
        data: d,
    }
}

#[test]
fn tx_encode_accepts_exactly_max_ota_payload() {
    // Kills `> MAX_OTA_PAYLOAD` → `>=` (the boundary-inclusive mutant).
    let cmd = tx_cmd(MAX_OTA_PAYLOAD);
    let mut buf = [0u8; MAX_OTA_PAYLOAD + 8];
    assert!(cmd.encode_payload(&mut buf).is_ok());
}

#[test]
fn tx_encode_rejects_max_ota_payload_plus_one() {
    // Kills `> MAX_OTA_PAYLOAD` → `==` — a heapless::Vec can't hold
    // more than MAX_OTA_PAYLOAD, so we reach the limit via the parse
    // path with an oversized slice.
    let mut bytes: HVec<u8, { MAX_OTA_PAYLOAD + 2 }> = HVec::new();
    bytes.push(0x00).unwrap();
    for _ in 0..=MAX_OTA_PAYLOAD {
        bytes.push(0x42).unwrap();
    }
    assert!(matches!(
        Command::parse(commands::TYPE_TX, &bytes),
        Err(CommandParseError::WrongLength)
    ));
}

#[test]
fn tx_encode_rejects_buffer_exactly_one_byte_too_small() {
    // Kills `buf.len() < total` → `==` and `<=`.
    let cmd = tx_cmd(5);
    // total = 1 (flags) + 5 (data) = 6
    let mut exact = [0u8; 6];
    assert!(cmd.encode_payload(&mut exact).is_ok());

    let mut too_small = [0u8; 5];
    assert!(matches!(
        cmd.encode_payload(&mut too_small),
        Err(CommandEncodeError::BufferTooSmall)
    ));
}

#[test]
fn tx_parse_body_exactly_max_ota_payload() {
    // Kills `body.len() > MAX_OTA_PAYLOAD` → `==`.
    let mut bytes: HVec<u8, { MAX_OTA_PAYLOAD + 2 }> = HVec::new();
    bytes.push(0x00).unwrap();
    for _ in 0..MAX_OTA_PAYLOAD {
        bytes.push(0x42).unwrap();
    }
    let cmd = Command::parse(commands::TYPE_TX, &bytes).unwrap();
    match cmd {
        Command::Tx { data, .. } => assert_eq!(data.len(), MAX_OTA_PAYLOAD),
        _ => panic!("expected Tx"),
    }
}

// ── SetConfigResultCode / Owner enum coverage (events.rs) ───────────

#[test]
fn set_config_result_code_as_u8_exact() {
    // Kills `SetConfigResultCode::as_u8 -> u8 with 0` by pinning each
    // non-zero variant's discriminant.
    assert_eq!(SetConfigResultCode::Applied.as_u8(), 0);
    assert_eq!(SetConfigResultCode::AlreadyMatched.as_u8(), 1);
    assert_eq!(SetConfigResultCode::LockedMismatch.as_u8(), 2);
}

#[test]
fn set_config_result_code_from_u8_exact() {
    // Kills match-arm-deletion mutants (1, 2) by asserting each maps.
    assert_eq!(
        SetConfigResultCode::from_u8(0),
        Some(SetConfigResultCode::Applied),
    );
    assert_eq!(
        SetConfigResultCode::from_u8(1),
        Some(SetConfigResultCode::AlreadyMatched),
    );
    assert_eq!(
        SetConfigResultCode::from_u8(2),
        Some(SetConfigResultCode::LockedMismatch),
    );
    assert_eq!(SetConfigResultCode::from_u8(3), None);
}

#[test]
fn owner_as_u8_exact() {
    // Kills `Owner::as_u8 -> u8 with 1`.
    assert_eq!(Owner::None.as_u8(), 0);
    assert_eq!(Owner::Mine.as_u8(), 1);
    assert_eq!(Owner::Other.as_u8(), 2);
}

#[test]
fn owner_from_u8_exact() {
    // Kills match-arm-deletion mutants (0, 2).
    assert_eq!(Owner::from_u8(0), Some(Owner::None));
    assert_eq!(Owner::from_u8(1), Some(Owner::Mine));
    assert_eq!(Owner::from_u8(2), Some(Owner::Other));
    assert_eq!(Owner::from_u8(3), None);
}

// ── SetConfigResult encode/decode length boundaries ────────────────

fn sample_lora_for_scr() -> LoRaConfig {
    LoRaConfig {
        freq_hz: 915_000_000,
        sf: 7,
        bw: LoRaBandwidth::Khz125,
        cr: LoRaCodingRate::Cr4_5,
        preamble_len: 8,
        sync_word: 0x1424,
        tx_power_dbm: 14,
        header_mode: LoRaHeaderMode::Explicit,
        payload_crc: true,
        iq_invert: false,
    }
}

#[test]
fn set_config_result_encode_rejects_exactly_one_byte_buf() {
    // Kills `buf.len() < 2` → `==` / `<=`.
    let r = SetConfigResult {
        result: SetConfigResultCode::Applied,
        owner: Owner::Mine,
        current: Modulation::LoRa(sample_lora_for_scr()),
    };
    let mut one = [0u8; 1];
    assert!(r.encode(&mut one).is_err());
    let mut enough = [0u8; 32];
    assert!(r.encode(&mut enough).is_ok());
}

#[test]
fn set_config_result_decode_rejects_exactly_one_byte_buf() {
    // Kills `buf.len() < 2` → `==` / `<=` on the decode side.
    assert!(matches!(
        SetConfigResult::decode(&[]),
        Err(DeviceMessageParseError::TooShort)
    ));
    assert!(matches!(
        SetConfigResult::decode(&[0x00]),
        Err(DeviceMessageParseError::TooShort)
    ));
}

// ── OkPayload::parse_for dispatches each command cleanly ───────────

#[test]
fn ok_payload_parses_get_info() {
    // Kills `delete match arm commands::TYPE_GET_INFO`.
    let mut info_buf = [0u8; 128];
    let info = sample_info();
    let n = info.encode(&mut info_buf).unwrap();
    let parsed = OkPayload::parse_for(commands::TYPE_GET_INFO, &info_buf[..n]).unwrap();
    assert!(matches!(parsed, OkPayload::Info(_)));
}

#[test]
fn ok_payload_parses_set_config() {
    // Kills `delete match arm commands::TYPE_SET_CONFIG`.
    let r = SetConfigResult {
        result: SetConfigResultCode::Applied,
        owner: Owner::Mine,
        current: Modulation::LoRa(sample_lora_for_scr()),
    };
    let mut buf = [0u8; 64];
    let n = r.encode(&mut buf).unwrap();
    let parsed = OkPayload::parse_for(commands::TYPE_SET_CONFIG, &buf[..n]).unwrap();
    assert!(matches!(parsed, OkPayload::SetConfig(_)));
}

// ── DeviceMessage::parse covers each type_id ───────────────────────

#[test]
fn device_message_parses_err() {
    // Kills `delete match arm TYPE_ERR` in DeviceMessage::parse.
    let mut err_buf = [0u8; 2];
    events::encode_err_payload(ErrorCode::EBusy, &mut err_buf).unwrap();
    let parsed = DeviceMessage::parse(events::TYPE_ERR, &err_buf, None).unwrap();
    assert!(matches!(parsed, DeviceMessage::Err(ErrorCode::EBusy)));
}

#[test]
fn device_message_parses_tx_done() {
    // Kills `delete match arm TYPE_TX_DONE`.
    let td = TxDonePayload {
        result: TxResult::Transmitted,
        airtime_us: 42,
    };
    let mut buf = [0u8; 8];
    let n = td.encode(&mut buf).unwrap();
    let parsed = DeviceMessage::parse(events::TYPE_TX_DONE, &buf[..n], None).unwrap();
    assert!(matches!(
        parsed,
        DeviceMessage::TxDone(TxDonePayload { airtime_us: 42, .. })
    ));
}

// ── RxPayload::decode length math (events.rs:255) ──────────────────

#[test]
fn rx_payload_decode_subtracts_metadata_size() {
    // `n = buf.len() - METADATA_SIZE` — kills `- -> /`. If `-` is
    // replaced with `/`, the resulting n would be `buf.len() / 20`
    // which for a 21-byte buffer yields 1 (matches the truth) — but
    // for a 40-byte buffer yields 2 (wrong; truth is 20). Use a
    // buffer length that makes the two calculations disagree.
    let mut meta = [0u8; 20];
    meta[0..2].copy_from_slice(&(-100i16).to_le_bytes());
    meta[16] = 1; // crc_valid
    meta[19] = 0; // origin Ota
    let mut buf: HVec<u8, 64> = HVec::new();
    buf.extend_from_slice(&meta).unwrap();
    buf.extend_from_slice(&[0x01u8; 20]).unwrap();
    let decoded = RxPayload::decode(&buf).unwrap();
    assert_eq!(decoded.data.len(), 20);
}

// ── FrameDecoder reset actually clears state ──────────────────────

#[test]
fn frame_decoder_reset_clears_accumulator() {
    // Kills `replace FrameDecoder::reset with ()`.
    let mut decoder = FrameDecoder::new();
    // Feed a half-frame: bytes without a 0x00 terminator.
    decoder.feed(&[0xAA, 0xBB, 0xCC, 0xDD], |_| {});
    decoder.reset();
    // Now feed a complete, valid frame. The accumulator must not carry
    // the stale bytes — if `reset` is a no-op, the decoder prepends
    // them and the CRC / COBS decode fails.
    let mut wire = [0u8; MAX_WIRE_FRAME];
    let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
    let mut saw_ok = false;
    decoder.feed(&wire[..n], |res| {
        if matches!(res, FrameResult::Ok { .. }) {
            saw_ok = true;
        }
    });
    assert!(saw_ok, "reset() must clear stale bytes");
}

// ── Frame decoder overflow || vs && branch (frame.rs:111) ──────────

#[test]
fn frame_decoder_stray_sentinel_does_not_emit() {
    // `self.overflowed || self.len == 0` mutated to `&&` would emit a
    // spurious error for a stray 0x00 (both `overflowed` and `len == 0`
    // must be true under `&&`, but on a naked `0x00` only `len == 0`
    // holds — so the branch stops triggering and the decoder tries to
    // COBS-decode an empty buffer).
    let mut decoder = FrameDecoder::new();
    let mut saw_any = false;
    decoder.feed(&[0x00], |_| {
        saw_any = true;
    });
    assert!(
        !saw_any,
        "stray 0x00 with empty buffer must not produce any frame"
    );
}

// ── Capability bit values (info.rs) ────────────────────────────────
//
// Kills the 14 `<< -> >>` mutants on the cap:: constants by pinning
// each bit's expected u64 value.

#[test]
fn cap_bit_values_pinned() {
    assert_eq!(cap::LORA, 1);
    assert_eq!(cap::FSK, 2);
    assert_eq!(cap::GFSK, 4);
    assert_eq!(cap::LR_FHSS, 8);
    assert_eq!(cap::FLRC, 16);
    assert_eq!(cap::MSK, 32);
    assert_eq!(cap::GMSK, 64);
    assert_eq!(cap::BLE_COMPATIBLE, 128);
    assert_eq!(cap::CAD_BEFORE_TX, 0x0001_0000);
    assert_eq!(cap::IQ_INVERSION, 0x0002_0000);
    assert_eq!(cap::RANGING, 0x0004_0000);
    assert_eq!(cap::GNSS_SCAN, 0x0008_0000);
    assert_eq!(cap::WIFI_MAC_SCAN, 0x0010_0000);
    assert_eq!(cap::SPECTRAL_SCAN, 0x0020_0000);
    assert_eq!(cap::FULL_DUPLEX, 0x0040_0000);
    assert_eq!(cap::MULTI_CLIENT, 0x0000_0001_0000_0000);
}

// ── Info::encode / decode boundaries ───────────────────────────────

fn sample_info() -> Info {
    let mut mcu = [0u8; MAX_MCU_UID_LEN];
    mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
    Info {
        proto_major: 1,
        proto_minor: 0,
        fw_major: 0,
        fw_minor: 1,
        fw_patch: 0,
        radio_chip_id: 0x0002,
        capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
        supported_sf_bitmap: 0x1FE0,
        supported_bw_bitmap: 0x03FF,
        max_payload_bytes: 255,
        rx_queue_capacity: 32,
        tx_queue_capacity: 1,
        freq_min_hz: 150_000_000,
        freq_max_hz: 960_000_000,
        tx_power_min_dbm: -9,
        tx_power_max_dbm: 22,
        mcu_uid_len: 8,
        mcu_uid: mcu,
        radio_uid_len: 0,
        radio_uid: [0u8; MAX_RADIO_UID_LEN],
    }
}

#[test]
fn info_encode_rejects_buffer_exactly_one_byte_too_small() {
    // Kills `buf.len() < total` → `==` / `<=` in Info::encode.
    let i = sample_info();
    // 37 fixed + 8 mcu_uid + 0 radio_uid = 45 bytes required.
    let mut exact = [0u8; 45];
    assert!(i.encode(&mut exact).is_ok());

    let mut too_small = [0u8; 44];
    assert!(i.encode(&mut too_small).is_err());
}

#[test]
fn info_decode_rejects_mcu_uid_len_overrun() {
    // Kills the `<` boundary checks and `+` arithmetic in Info::decode
    // by constructing an `mcu_uid_len` that extends past the buffer.
    let i = sample_info();
    let mut buf = [0u8; 128];
    let n = i.encode(&mut buf).unwrap();
    // Pretend mcu_uid_len is 30 so radio_len_idx is 66, beyond n=45.
    buf[35] = 30;
    assert!(matches!(
        Info::decode(&buf[..n]),
        Err(donglora_protocol::InfoParseError::TooShort)
    ));
}

#[test]
fn info_decode_offsets_are_correct() {
    // Implicitly kills the `+ -> -` and `+ -> *` mutants in
    // Info::decode's offset calculations by roundtripping a
    // non-zero radio_uid_len AND non-zero mcu_uid_len — any arithmetic
    // bug flips the data between the two UIDs.
    let mut i = sample_info();
    let mut radio_uid = [0u8; MAX_RADIO_UID_LEN];
    radio_uid[..4].copy_from_slice(&[0xCA, 0xFE, 0xBA, 0xBE]);
    i.radio_uid_len = 4;
    i.radio_uid = radio_uid;

    let mut buf = [0u8; 128];
    let n = i.encode(&mut buf).unwrap();
    let decoded = Info::decode(&buf[..n]).unwrap();
    assert_eq!(
        &decoded.mcu_uid[..8],
        &[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]
    );
    assert_eq!(&decoded.radio_uid[..4], &[0xCA, 0xFE, 0xBA, 0xBE]);
}

#[test]
fn info_min_wire_size_pinned() {
    // Kills mutants touching the fixed prefix size.
    assert_eq!(Info::MIN_WIRE_SIZE, 37);
}

// ── FskConfig::encode buffer-size boundary ─────────────────────────

#[test]
fn fsk_encode_rejects_buffer_exactly_one_byte_too_small() {
    // Kills `buf.len() < total` → `==` / `<=` in FskConfig::encode.
    let mut cfg = donglora_protocol::FskConfig {
        freq_hz: 0,
        bitrate_bps: 0,
        freq_dev_hz: 0,
        rx_bw: 0,
        preamble_len: 0,
        sync_word_len: 4,
        sync_word: [0u8; donglora_protocol::MAX_SYNC_WORD_LEN],
    };
    cfg.sync_word[..4].copy_from_slice(&[1, 2, 3, 4]);
    // total = 16 + 4 = 20
    let mut exact = [0u8; 20];
    assert!(cfg.encode(&mut exact).is_ok());

    let mut too_small = [0u8; 19];
    assert!(cfg.encode(&mut too_small).is_err());
}

// ── Frame encode buffer boundary (frame.rs:68) ─────────────────────

#[test]
fn encode_frame_rejects_output_buffer_exactly_one_byte_too_small() {
    // Kills `out.len() < total` → `<=` in encode_frame.
    //
    // We need to know the exact encoded size for our chosen payload.
    let mut generous = [0u8; MAX_WIRE_FRAME];
    let n = encode_frame(0x01, 0x0001, b"test", &mut generous).unwrap();
    assert!(n >= 3);
    let mut exact = [0u8; MAX_WIRE_FRAME];
    assert!(encode_frame(0x01, 0x0001, b"test", &mut exact[..n]).is_ok());
    assert!(encode_frame(0x01, 0x0001, b"test", &mut exact[..n - 1]).is_err());
}

// ── frame.rs MAX_COBS_ENCODED arithmetic (frame.rs:38) ─────────────

#[test]
fn max_wire_frame_minus_one_leaves_room_for_sentinel() {
    // Indirectly kills `MAX_COBS_ENCODED = MAX_WIRE_FRAME - 1` mutants:
    // encoding a maximum-payload frame must fit in exactly
    // MAX_WIRE_FRAME bytes, and no fewer.
    let big = [0x42u8; MAX_PAYLOAD_FIELD];
    let mut wire = [0u8; MAX_WIRE_FRAME];
    let n = encode_frame(0xC0, 0x0000, &big, &mut wire).unwrap();
    assert!(n <= MAX_WIRE_FRAME);
    assert!(n > MAX_PAYLOAD_FIELD);
}

// ── Unknown command type produces UnknownType, not WrongLength ─────

#[test]
fn parse_unknown_type_with_empty_payload() {
    // 0x10 is reserved (not a defined H→D command type). The parser
    // must surface UnknownType so the caller can emit EUNKNOWN_CMD.
    assert!(matches!(
        Command::parse(0x10, &[]),
        Err(CommandParseError::UnknownType)
    ));
}

// ── SetConfigResult::encode boundary (events.rs:103) ──────────────
//
// `buf.len() < 2` — the guard clamps writes to the result + owner
// bytes. A test that just flips the boundary to `is_err()` can't tell
// `<` from `<=` because the downstream `Modulation::encode` also fails.
// We distinguish by checking that buf[0] / buf[1] ARE written before
// the modulation encode fails: unmutated writes them; the `<=` mutant
// returns early and leaves the buffer untouched.

#[test]
fn set_config_result_encode_writes_header_before_modulation_fails() {
    let r = SetConfigResult {
        result: SetConfigResultCode::LockedMismatch,
        owner: Owner::Other,
        current: Modulation::LoRa(sample_lora_for_scr()),
    };
    let mut buf = [0xFFu8; 2];
    let res = r.encode(&mut buf);
    assert!(res.is_err(), "modulation encode needs more than 2 bytes");
    assert_eq!(
        buf[0],
        SetConfigResultCode::LockedMismatch.as_u8(),
        "result byte must be written before modulation encode fails",
    );
    assert_eq!(
        buf[1],
        Owner::Other.as_u8(),
        "owner byte must be written before modulation encode fails",
    );
}

// ── SetConfigResult::decode boundary (events.rs:116) ───────────────
//
// Unmutated: `buf.len() < 2` passes for len==2, then `Modulation::decode(&[])`
// returns `TooShort` → mapped to `WrongLength`. Mutated `<=`: guard trips,
// returns `TooShort`. Distinguish via the error variant.

#[test]
fn set_config_result_decode_two_byte_buf_is_wrong_length_not_too_short() {
    let res = SetConfigResult::decode(&[0x00, 0x01]);
    assert!(
        matches!(res, Err(DeviceMessageParseError::WrongLength)),
        "decode of a 2-byte buffer must surface WrongLength from the inner \
         Modulation::decode call, not TooShort from the guard",
    );
}

// ── RxPayload::decode uses `n` for the data slice (events.rs:255) ─
//
// After the refactor, `n = buf.len() - METADATA_SIZE` is load-bearing:
// the data slice is `&buf[METADATA_SIZE..METADATA_SIZE + n]`. A mutant
// that swaps `-` for `/` yields a smaller n and a shorter data slice,
// which the round-trip observes.

#[test]
fn rx_payload_encode_accepts_exactly_max_ota_payload() {
    // Kills `data.len() > MAX_OTA_PAYLOAD` → `==` in RxPayload::encode.
    // At exactly MAX_OTA_PAYLOAD, the `==` mutant trips
    // PayloadTooLarge where the unmutated path encodes cleanly.
    let mut data: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
    for i in 0..MAX_OTA_PAYLOAD {
        data.push(i as u8).unwrap();
    }
    let rx = RxPayload {
        rssi_tenths_dbm: -735,
        snr_tenths_db: 95,
        freq_err_hz: 0,
        timestamp_us: 0,
        crc_valid: true,
        packets_dropped: 0,
        origin: RxOrigin::Ota,
        data,
    };
    let mut buf = [0u8; 20 + MAX_OTA_PAYLOAD];
    let n = rx.encode(&mut buf).unwrap();
    assert_eq!(n, 20 + MAX_OTA_PAYLOAD);
}

#[test]
fn rx_payload_encode_accepts_buffer_exactly_equal_to_total() {
    // Kills `buf.len() < total` → `==` in RxPayload::encode. With the
    // `==` mutant, an exactly-sized buffer trips BufferTooSmall instead
    // of succeeding.
    let mut data: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
    data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
    let rx = RxPayload {
        rssi_tenths_dbm: -735,
        snr_tenths_db: 95,
        freq_err_hz: -125,
        timestamp_us: 42_000_000,
        crc_valid: true,
        packets_dropped: 0,
        origin: RxOrigin::Ota,
        data,
    };
    // total = 20 metadata + 4 data = 24. Exact fit.
    let mut buf = [0u8; 24];
    let n = rx.encode(&mut buf).unwrap();
    assert_eq!(n, 24);
}

#[test]
fn rx_payload_decode_accepts_exactly_max_ota_payload() {
    // Kills `n > MAX_OTA_PAYLOAD` → `==` in RxPayload::decode. A buffer
    // with exactly 20 + MAX_OTA_PAYLOAD bytes means n == MAX_OTA_PAYLOAD;
    // the `==` mutant trips and returns WrongLength, unmutated decodes
    // cleanly with full-length data.
    let mut buf = [0u8; 20 + MAX_OTA_PAYLOAD];
    buf[16] = 1; // crc_valid
    buf[19] = 0; // origin Ota
    for (i, b) in buf[20..].iter_mut().enumerate() {
        *b = i as u8;
    }
    let decoded = RxPayload::decode(&buf).unwrap();
    assert_eq!(decoded.data.len(), MAX_OTA_PAYLOAD);
    assert_eq!(decoded.data[0], 0);
    assert_eq!(
        decoded.data[MAX_OTA_PAYLOAD - 1],
        (MAX_OTA_PAYLOAD - 1) as u8
    );
}

#[test]
fn rx_payload_decode_accepts_exactly_metadata_size_no_data() {
    // Kills both `buf.len() < METADATA_SIZE` → `==` / `<=` mutants on
    // `RxPayload::decode`. A 20-byte buffer (metadata only, zero data)
    // must succeed: unmutated returns an RxEvent with empty `data`;
    // either mutant trips the guard and returns TooShort.
    let mut buf = [0u8; 20];
    buf[16] = 1; // crc_valid
    buf[19] = 0; // origin Ota
    let decoded = RxPayload::decode(&buf).unwrap();
    assert_eq!(decoded.data.len(), 0);
}

#[test]
fn rx_payload_decode_rejects_one_byte_below_metadata_size() {
    // Complements the above: a 19-byte buffer must fail regardless of
    // how the `<` boundary is mutated, since the data slice
    // `buf[METADATA_SIZE..]` would otherwise index out of bounds.
    let buf = [0u8; 19];
    assert!(matches!(
        RxPayload::decode(&buf),
        Err(DeviceMessageParseError::TooShort),
    ));
}

#[test]
fn rx_payload_decode_n_is_used_for_data_slice() {
    let mut buf = [0u8; 40];
    buf[16] = 1; // crc_valid
    buf[19] = 0; // origin Ota
    for (i, b) in buf[20..40].iter_mut().enumerate() {
        *b = i as u8;
    }
    let decoded = RxPayload::decode(&buf).unwrap();
    // Unmutated: n = 40 - 20 = 20 → data slice is all 20 bytes.
    // Mutated `-` → `/`: n = 40 / 20 = 2 → data slice is only 2 bytes.
    assert_eq!(decoded.data.len(), 20);
    assert_eq!(decoded.data[0], 0);
    assert_eq!(decoded.data[19], 19);
}

// ── Info sample for the tests below ────────────────────────────────

fn info_sample() -> Info {
    let mut mcu = [0u8; MAX_MCU_UID_LEN];
    mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
    Info {
        proto_major: 1,
        proto_minor: 0,
        fw_major: 0,
        fw_minor: 1,
        fw_patch: 0,
        radio_chip_id: 0x0002,
        capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
        supported_sf_bitmap: 0x1FE0,
        supported_bw_bitmap: 0x03FF,
        max_payload_bytes: 255,
        rx_queue_capacity: 32,
        tx_queue_capacity: 1,
        freq_min_hz: 150_000_000,
        freq_max_hz: 960_000_000,
        tx_power_min_dbm: -9,
        tx_power_max_dbm: 22,
        mcu_uid_len: 8,
        mcu_uid: mcu,
        radio_uid_len: 0,
        radio_uid: [0u8; MAX_RADIO_UID_LEN],
    }
}

// ── Info::decode min-size boundary (info.rs:132) ───────────────────
//
// A zero-UID Info encodes to exactly MIN_WIRE_SIZE (37) bytes. Decode
// of a 37-byte buffer must succeed. `<=` mutant makes it return
// TooShort.

#[test]
fn info_decode_accepts_exactly_min_wire_size() {
    let mut i = info_sample();
    i.mcu_uid_len = 0;
    i.radio_uid_len = 0;
    let mut buf = [0u8; 64];
    let n = i.encode(&mut buf).unwrap();
    assert_eq!(n, 37);
    let decoded = Info::decode(&buf[..n]).unwrap();
    assert_eq!(decoded.mcu_uid_len, 0);
    assert_eq!(decoded.radio_uid_len, 0);
}

// ── Info::decode `radio_len_idx + 1` guard (info.rs:141) ──────────
//
// The `+ 1` reserves the byte that holds `radio_uid_len`. A 44-byte
// buffer with mcu_uid_len=8 has radio_len_idx=44 — the check needs 45
// bytes, so 44 must produce TooShort. Mutants `+ → -` or `+ → *`
// either relax the guard and then panic reading buf[44], or simply
// allow the read to succeed with garbage data.

#[test]
fn info_decode_radio_len_idx_plus_one_guard_is_active() {
    let mut buf = [0u8; 44];
    buf[35] = 8; // mcu_uid_len so radio_len_idx lands at 44
    assert!(matches!(Info::decode(&buf), Err(InfoParseError::TooShort),));
}

// ── Info::decode `expected_total` uses both + (info.rs:149) ───────
//
// `Self::MIN_WIRE_SIZE + mcu_n + radio_n` — a mutation to `-` at
// either `+` produces a smaller expected_total that passes the guard
// at buf.len()==45 (which unmutated correctly rejects as TooShort).
// With the guard bypassed, decode panics reading radio_uid from a
// slice that extends past the buffer.

#[test]
fn info_decode_expected_total_uses_both_plus_signs() {
    let mut i = info_sample();
    i.radio_uid_len = 4;
    let mut big = [0u8; 128];
    let n = i.encode(&mut big).unwrap();
    assert_eq!(n, 49);
    assert!(matches!(
        Info::decode(&big[..45]),
        Err(InfoParseError::TooShort),
    ));
}

// ── Info::decode tolerates trailing bytes (info.rs:150) ───────────
//
// Per spec §16.4, minor-version extensions may append fields. The
// decoder's final length guard is `buf.len() < expected_total`; a
// mutant to `>` trips when buf.len() exceeds expected_total, which
// breaks forward-compatibility.

#[test]
fn info_decode_tolerates_trailing_bytes() {
    let i = info_sample();
    let mut buf = [0u8; 64];
    let n = i.encode(&mut buf).unwrap();
    assert_eq!(n, 45);
    // Simulate 10 bytes of a future-minor-version extension.
    for b in &mut buf[45..55] {
        *b = 0xAA;
    }
    let decoded = Info::decode(&buf[..55]).unwrap();
    assert_eq!(decoded, i);
}
