"""Protocol checks run without serial hardware or third-party packages."""

import math
from pathlib import Path
import struct
import sys
import unittest

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "software" / "examples"))

from hall_protocol import (CMD_GET_DATA, TYPE_ENCODER, TYPE_POSITION, Parser,
                           ProtocolError, build_frame, crc16, parse_info, parse_sample)
from read_sensor import transact


class WireProtocolTests(unittest.TestCase):
    def test_known_crc_vectors_and_request(self):
        self.assertEqual(crc16(b"123456789"), 0x4B37)
        self.assertEqual(crc16(b""), 0xFFFF)
        # Cross-checked against original host crc16; coverage is DST through LEN.
        self.assertEqual(build_frame(1, 0, 1, CMD_GET_DATA, 1).hex(" "),
                         "a5 5a 01 00 01 10 01 00 00 63")

    def test_every_split_boundary_and_concatenation(self):
        wire = build_frame(1, 0, 1, CMD_GET_DATA, 1)
        for cut in range(1, len(wire)):
            parser = Parser()
            self.assertEqual(parser.feed(wire[:cut]), [])
            frames = parser.feed(wire[cut:] + wire)
            self.assertEqual(len(frames), 2)
            self.assertEqual(frames[0].seq, 1)
        parser = Parser()
        frames = []
        for byte in b"noise\xa5" + wire + wire:
            frames.extend(parser.feed(bytes((byte,))))
        self.assertEqual(len(frames), 2)

    def test_bad_crc_is_rejected_and_next_frame_recovers(self):
        good = build_frame(1, 0, 1, CMD_GET_DATA, 1)
        bad = bytearray(good)
        bad[-1] ^= 0x80
        parser = Parser()
        self.assertEqual(parser.feed(bytes(bad)), [])
        self.assertEqual(len(parser.feed(good)), 1)
        self.assertEqual(parser.crc_errors, 1)

    def test_overlength_frame_recovers_and_builder_rejects(self):
        good = build_frame(1, 0, 1, CMD_GET_DATA, 1)
        parser = Parser()
        frames = parser.feed(b"\xa5\x5a\x01\x00\x01\x10\x01\x21" + good)
        self.assertEqual(len(frames), 1)
        self.assertEqual(parser.length_errors, 1)
        with self.assertRaises(ValueError):
            build_frame(1, 0, 1, 0x10, 1, bytes(33))

    def test_profile_changes_units_and_preserves_signed_line_value(self):
        angle = b"\x00" + struct.pack("<ihHHHB", 9000, 1000, 2048, 2000, 2100, 2)
        sample = parse_sample(angle, TYPE_ENCODER, host_rx_unix_s=100.0, host_rx_monotonic_s=5.0)
        self.assertAlmostEqual(sample.si_value, math.pi / 2)
        self.assertEqual(sample.to_dict()["si_unit"], "rad")
        self.assertEqual(sample.to_dict()["raw_unit"], "0.01_deg")
        self.assertEqual(sample.host_rx_unix_s, 100.0)
        self.assertIn("not_hardware_sample_time", sample.to_dict()["timestamp_kind"])
        line = b"\x00" + struct.pack("<ihHHHB", -1250, 1000, 2048, 2000, 2100, 8)
        sample = parse_sample(line, TYPE_POSITION)
        self.assertEqual(sample.raw_value, -1250)
        self.assertAlmostEqual(sample.si_value, -0.0125)
        self.assertEqual(sample.to_dict()["si_unit"], "m")
        self.assertEqual(sample.to_dict()["flag_names"], ["out_of_array"])

    def test_response_status_report_layout_and_unknown_profile(self):
        raw = struct.pack("<ihHHHB", 1234, 80, 1, 2, 3, 1)
        self.assertEqual(parse_sample(raw, TYPE_ENCODER, with_status=False).raw_value, 1234)
        with self.assertRaisesRegex(ProtocolError, "status 0x04"):
            parse_sample(b"\x04", TYPE_ENCODER)
        with self.assertRaises(ProtocolError):
            parse_sample(raw, TYPE_ENCODER)
        with self.assertRaises(ProtocolError):
            parse_sample(b"\x00" + raw, 0xFF)

    def test_info_uses_signed_zero_only_for_line_profile(self):
        info = bytes((0, 2, 1, 0, 4)) + struct.pack("<HHBBh", 20, 0x0101, 1, 2, -120)
        parsed = parse_info(info)
        self.assertEqual(parsed["dev_type"], TYPE_POSITION)
        self.assertEqual(parsed["report_ms"], 20)
        self.assertEqual(parsed["zero_raw"], -120)

    def test_transaction_ignores_other_device_stale_seq_and_reports(self):
        sample = b"\x00" + struct.pack("<ihHHHB", -1250, 1000, 2048, 2200, 2500, 2)
        expected = build_frame(0, 1, 2, 0x90, 7, sample)
        unrelated = b"".join((
            build_frame(0, 2, 2, 0x90, 7, sample),  # Another device.
            build_frame(0, 1, 2, 0x90, 6, sample),  # Old transaction.
            build_frame(0, 1, 1, 0x90, 7, sample),  # Wrong profile/units.
            build_frame(3, 1, 2, 0x90, 7, sample),  # Another destination.
            build_frame(0, 1, 2, 0x30, 7, sample[1:]),  # An unsolicited report.
        ))

        class FakePort:
            def __init__(self, incoming):
                self.incoming = bytearray(incoming)
                self.written = b""

            @property
            def in_waiting(self):
                return len(self.incoming)

            def write(self, data):
                self.written += data
                return len(data)

            def flush(self):
                pass

            def read(self, size):
                data = bytes(self.incoming[:size])
                del self.incoming[:size]
                return data

        port = FakePort(unrelated + expected)
        frame = transact(port, Parser(), 1, 2, 0x10, 7, 0.1)
        self.assertEqual(frame.payload, sample)
        self.assertEqual(port.written, build_frame(1, 0, 2, 0x10, 7))
        with self.assertRaises(TimeoutError):
            transact(FakePort(b""), Parser(), 1, 2, 0x10, 7, 0.001)

        class ShortWritePort(FakePort):
            def write(self, data):
                return 1

        with self.assertRaisesRegex(ProtocolError, "complete request"):
            transact(ShortWritePort(b""), Parser(), 1, 2, 0x10, 7, 0.1)

    def test_incomplete_false_header_recovers_after_idle_gap(self):
        good = build_frame(1, 0, 1, CMD_GET_DATA, 1)
        false_header = b"\xa5\x5a\x01\x00\x01\x10\x01\x20"
        parser = Parser()
        self.assertEqual(parser.feed(false_header + good, now=1.0), [])
        self.assertEqual(parser.feed(b"", now=1.01), [])
        self.assertEqual(len(parser.feed(b"", now=1.03)), 1)
        self.assertEqual(parser.partial_timeouts, 1)

    def test_invalid_information_modes_and_calibration_bits_are_rejected(self):
        info = bytes((0, 2, 1, 0, 4)) + struct.pack("<HHBBh", 20, 0x0101, 1, 2, -120)
        for index, value in ((2, 0), (3, 2), (4, 8), (5, 1), (9, 0), (10, 0x82)):
            bad = bytearray(info)
            bad[index] = value
            with self.subTest(index=index, value=value), self.assertRaises(ProtocolError):
                parse_info(bytes(bad))

    def test_bad_codes_are_rejected_and_line_zero_offset_is_not_clamped(self):
        for value, amp, adc, flags in ((36000, 80, 1000, 2), (-1, 80, 1000, 2),
                                        (1234, -1, 1000, 2), (1234, 80, 4096, 2),
                                        (1234, 80, 1000, 8)):
            raw = b"\x00" + struct.pack("<ihHHHB", value, amp, adc, 2, 3, flags)
            with self.subTest(value=value, amp=amp, adc=adc, flags=flags), self.assertRaises(ProtocolError):
                parse_sample(raw, TYPE_ENCODER)
        raw = b"\x00" + struct.pack("<ihHHHB", -2000, 80, 1000, 2, 3, 2)
        sample = parse_sample(raw, TYPE_POSITION)
        self.assertEqual(sample.raw_value, -2000)
        self.assertAlmostEqual(sample.si_value, -0.02)

    def test_warning_flags_are_exposed_without_claiming_control_validity(self):
        raw = b"\x00" + struct.pack("<ihHHHB", 10, 10, 1, 2, 3, 0x0D)
        row = parse_sample(raw, TYPE_POSITION).to_dict()
        self.assertFalse(row["calibration_valid_flag"])
        self.assertEqual(row["measurement_warnings"],
                         ["weak_field", "calibrating", "outside_array_or_interpolation",
                          "calibration_not_recorded"])


if __name__ == "__main__":
    unittest.main()
