"""End-to-end tests for libxrk 86 XRK/XRZ file reading."""

import unittest
from pathlib import Path
from typing import ClassVar

import numpy as np
import pyarrow as pa
from parameterized import parameterized

from libxrk import aim_xrk
from libxrk.base import ChannelMetadata, LogFile


# Path to test data
TEST_DATA_DIR = Path(__file__).parent / "test_data"
XRK_86_FILE = TEST_DATA_DIR / "86" / "CMD_Inferno 86_Fuji GP Sh_Generic testing_a_2248.xrk"
XRZ_86_FILE = TEST_DATA_DIR / "86" / "CMD_Inferno 86_Fuji GP Sh_Generic testing_a_2248.xrz"

# Test file variants for parameterized tests
FILE_VARIANTS = [
    ("xrk", XRK_86_FILE),
    ("xrz", XRZ_86_FILE),
]


class Test86XRK(unittest.TestCase):
    """Tests for loading and parsing the 86 test vector XRK/XRZ files."""

    @parameterized.expand(FILE_VARIANTS)
    def test_86_file_exists(self, name, file_path):
        """Verify the test data file exists."""
        self.assertTrue(file_path.exists(), f"Test file not found: {file_path}")

    @parameterized.expand(FILE_VARIANTS)
    def test_load_86_xrk_file(self, name, file_path):
        """Test loading the 86 XRK/XRZ file."""
        # Load the file
        log = aim_xrk(str(file_path), progress=None)

        # Verify basic structure
        self.assertIsNotNone(log, "aim_xrk returned None")
        self.assertIsNotNone(log.channels, "LogFile.channels is None")
        self.assertIsNotNone(log.laps, "LogFile.laps is None")
        self.assertIsNotNone(log.metadata, "LogFile.metadata is None")

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_metadata(self, name, file_path):
        """Test that the 86 XRK/XRZ file contains metadata."""
        log = aim_xrk(str(file_path), progress=None)

        # Should have metadata
        self.assertIsInstance(log.metadata, dict, "Expected metadata to be a dict")

        # Expected metadata - core fields that should be present in both XRK and XRZ
        core_metadata = {
            "Driver": "CMD",
            "Log Date": "11/01/2025",
            "Log Time": "10:39:06",
            "Vehicle": "Inferno 86",
            "Venue": "Fuji GP Sh",
            "Odo/System Distance (km)": 5313.42,
            "Odo/System Time": "79:29:53",
            "Odo/Usr 1 Distance (km)": 5313.42,
            "Odo/Usr 1 Time": "79:29:53",
            "Odo/Usr 2 Distance (km)": 5313.42,
            "Odo/Usr 2 Time": "79:29:53",
            "Odo/Usr 3 Distance (km)": 5313.42,
            "Odo/Usr 3 Time": "79:29:53",
            "Odo/Usr 4 Distance (km)": 5313.42,
            "Odo/Usr 4 Time": "79:29:53",
            "Logger ID": 6701209,
            "Logger Model ID": 649,
            "Logger Model": "MXP 1.3",
            "Device Name": "Inferno 86 v2",
            "GPS Receiver": "GPS",
        }

        # Check core fields present in both formats
        for key, expected_value in core_metadata.items():
            self.assertIn(key, log.metadata, f"Missing metadata key: {key}")
            self.assertEqual(log.metadata[key], expected_value, f"Metadata mismatch for {key}")

        # XRK-specific fields (may be missing or empty in XRZ)
        if name == "xrk":
            self.assertEqual(
                log.metadata.get("Long Comment"), "Front 15, 2/2\r\nRear 20 3/3\r\nA052 Used"
            )
            self.assertEqual(log.metadata.get("Series"), "Fuji Practice")
            self.assertEqual(log.metadata.get("Session"), "Generic testing")

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_expansion_devices(self, name, file_path):
        """Test that the 86 XRK/XRZ file contains expansion device metadata."""
        from typing import cast

        log = aim_xrk(str(file_path), progress=None)

        # Should have expansion devices
        self.assertIn("Expansion Devices", log.metadata, "Missing Expansion Devices metadata")
        devices = cast(list[dict[str, str]], log.metadata["Expansion Devices"])
        self.assertIsInstance(devices, list, "Expansion Devices should be a list")
        self.assertEqual(len(devices), 2, f"Expected 2 expansion devices, got {len(devices)}")

        # First device: TOYOTA CAN bus (enriched with iSLV hardware IDs)
        device1 = devices[0]
        self.assertEqual(device1.get("Bus Unit"), "1")
        self.assertEqual(device1.get("Bus Type"), "CAN")
        self.assertEqual(device1.get("Version"), "02.00.07")
        self.assertEqual(device1.get("Manufacturer"), "TOYOTA")
        self.assertEqual(device1.get("Model"), "GT86 SCION FRS")
        self.assertEqual(device1.get("Logger ID"), 8712324)
        self.assertEqual(device1.get("Model ID"), 1313)

        # Second device: Custom CAN expansion
        device2 = devices[1]
        self.assertEqual(device2.get("Bus Unit"), "2")
        self.assertEqual(device2.get("Bus Type"), "CAN")
        self.assertEqual(device2.get("Version"), "00.00.00")
        self.assertEqual(device2.get("Manufacturer"), "Inferno Racing")
        self.assertEqual(device2.get("Model"), "86 Can 2")
        self.assertEqual(device2.get("Logger ID"), 7201204)
        self.assertEqual(device2.get("Model ID"), 739)

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_vehicle_electronics_type(self, name, file_path):
        """Test that VET metadata is exposed."""
        log = aim_xrk(str(file_path), progress=None)
        self.assertEqual(log.metadata["Vehicle Electronics Type"], 0)

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_no_race_mode(self, name, file_path):
        """Test that 86 file has no Race Mode string (only byte flag)."""
        log = aim_xrk(str(file_path), progress=None)
        self.assertNotIn("Race Mode", log.metadata)

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_calibrations(self, name, file_path):
        """Test that calibration metadata is exposed with channel cross-references."""
        log = aim_xrk(str(file_path), progress=None)
        cals = log.metadata["Calibrations"]
        self.assertEqual(len(cals), 10)

        # All 86 calibrations are type 20 (IMU bias)
        for cal in cals:
            self.assertEqual(cal["type"], 20)
            self.assertIn("channel", cal)

        # Check specific channels are present
        cal_channels = {c["channel"] for c in cals}
        self.assertIn("InlineAcc", cal_channels)
        self.assertIn("LateralAcc", cal_channels)
        self.assertIn("YawRate", cal_channels)
        self.assertIn("LF_Shock_Pot", cal_channels)

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_laps(self, name, file_path):
        """Test that the 86 XRK/XRZ file contains lap data."""
        log = aim_xrk(str(file_path), progress=None)

        # Should have laps as a PyArrow table
        self.assertIsInstance(log.laps, pa.Table, "Expected laps to be a PyArrow Table")

        # Check that table has the expected columns
        self.assertIn("num", log.laps.column_names, "Laps table missing 'num' column")
        self.assertIn("start_time", log.laps.column_names, "Laps table missing 'start_time' column")
        self.assertIn("end_time", log.laps.column_names, "Laps table missing 'end_time' column")

        # Should have exactly 16 laps
        self.assertEqual(len(log.laps), 16, f"Expected 16 laps, got {len(log.laps)}")

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_specific_lap_times(self, name, file_path):
        """Test that specific lap times match expected values."""
        log = aim_xrk(str(file_path), progress=None)

        # Expected lap data (lap_num, start_time, end_time)
        # Values now match official AIM DLL (uses LAP messages, not GPS detection)
        expected_laps = [
            (0, 0, 150454),
            (1, 150454, 279602),
            (2, 279602, 406240),
            (3, 406240, 532797),
            (4, 532797, 659283),
            (5, 659283, 787773),
            (6, 787773, 913776),
            (7, 913776, 1041398),
            (8, 1041398, 1168323),
            (9, 1168323, 1294676),
            (10, 1294676, 1420573),
            (11, 1420573, 1547568),
            (12, 1547568, 1672956),
            (13, 1672956, 1799132),
            (14, 1799132, 1924188),
            (15, 1924188, 2161607),
        ]

        self.assertEqual(len(log.laps), len(expected_laps), "Lap count mismatch")

        for expected_num, expected_start, expected_end in expected_laps:
            lap_num = log.laps.column("num")[expected_num].as_py()
            start_time = log.laps.column("start_time")[expected_num].as_py()
            end_time = log.laps.column("end_time")[expected_num].as_py()

            self.assertEqual(lap_num, expected_num, f"Lap number mismatch at index {expected_num}")
            self.assertAlmostEqual(
                start_time,
                expected_start,
                delta=1.0,
                msg=f"Lap {expected_num} start time mismatch",
            )
            self.assertAlmostEqual(
                end_time, expected_end, delta=1.0, msg=f"Lap {expected_num} end time mismatch"
            )

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_channel_count_and_names(self, name, file_path):
        """Test that all expected channels are present with correct names."""
        log = aim_xrk(str(file_path), progress=None)

        expected_channels = {
            "AmbientTemp",
            "Baro",
            "Best Run Diff",
            "Best Today Diff",
            "BrakePress",
            "BrakeSw",
            "CAT1",
            "CH",
            "ClutchSw",
            "ECT",
            "External Voltage",
            "FL_Ch1",
            "FL_Ch2",
            "FL_Ch3",
            "FL_Ch4",
            "FL_Ch5",
            "FL_Ch6",
            "FL_Ch7",
            "FL_Ch8",
            "FR_Ch1",
            "FR_Ch2",
            "FR_Ch3",
            "FR_Ch4",
            "FR_Ch5",
            "FR_Ch6",
            "FR_Ch7",
            "FR_Ch8",
            "GPS Altitude",
            "GPS Latitude",
            "GPS Longitude",
            "GPS Speed",
            "GPS_Fix",
            "GPS_InlineAcc",
            "GPS_LateralAcc",
            "GPS_Position_Accuracy",
            "GPS_Satellites",
            "GPS_Velocity_Accuracy",
            "GPS_Yaw_Rate",
            "GPS_pDOP",
            "Gear",
            "InlineAcc",
            "IntakeAirT",
            "LF_Shock_Pot",
            "LR_Shock_Pot",
            "Lambda",
            "LateralAcc",
            "LoggerTemp",
            "Luminosity",
            "MAP",
            "OilTemp",
            "PPS",
            "PitchRate",
            "Predictive Time",
            "Prev Lap Diff",
            "RF_Shock_Pot",
            "RL_Ch1",
            "RL_Ch2",
            "RL_Ch3",
            "RL_Ch4",
            "RL_Ch5",
            "RL_Ch6",
            "RL_Ch7",
            "RL_Ch8",
            "RPM",
            "RR_Ch1",
            "RR_Ch2",
            "RR_Ch3",
            "RR_Ch4",
            "RR_Ch5",
            "RR_Ch6",
            "RR_Ch7",
            "RR_Ch8",
            "RR_Shock_Pot",
            "Ref Lap Diff",
            "RollRate",
            "SpeedAverage",
            "SteerAngle",
            "TPMS_ALM_LF",
            "TPMS_ALM_LR",
            "TPMS_ALM_RF",
            "TPMS_ALM_RR",
            "TPMS_Press_LF",
            "TPMS_Press_LR",
            "TPMS_Press_RF",
            "TPMS_Press_RR",
            "TPMS_Temp_LF",
            "TPMS_Temp_LR",
            "TPMS_Temp_RF",
            "TPMS_Temp_RR",
            "TPMS_Volt_LF",
            "TPMS_Volt_LR",
            "TPMS_Volt_RF",
            "TPMS_Volt_RR",
            "TPS",
            "VerticalAcc",
            "WheelSpdFL",
            "WheelSpdFR",
            "WheelSpdRL",
            "WheelSpdRR",
            "YawRate",
        }

        actual_channels = set(log.channels.keys())
        self.assertEqual(
            actual_channels,
            expected_channels,
            f"Channel names mismatch.\nMissing: {expected_channels - actual_channels}\nExtra: {actual_channels - expected_channels}",
        )

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_channel_row_counts(self, name, file_path):
        """Test that channels have the expected number of rows."""
        log = aim_xrk(str(file_path), progress=None)

        # Expected row counts for each channel
        expected_row_counts = {
            "AmbientTemp": 100396,
            "Baro": 100395,
            "Best Run Diff": 963,
            "Best Today Diff": 15,
            "BrakePress": 36024,
            "BrakeSw": 21617,
            "CAT1": 100396,
            "CH": 10807,
            "ClutchSw": 54029,
            "ECT": 100395,
            "External Voltage": 2161,
            "FL_Ch1": 21565,
            "FL_Ch2": 21565,
            "FL_Ch3": 21565,
            "FL_Ch4": 21565,
            "FL_Ch5": 21565,
            "FL_Ch6": 21565,
            "FL_Ch7": 21565,
            "FL_Ch8": 21565,
            "FR_Ch1": 21568,
            "FR_Ch2": 21568,
            "FR_Ch3": 21568,
            "FR_Ch4": 21568,
            "FR_Ch5": 21568,
            "FR_Ch6": 21568,
            "FR_Ch7": 21568,
            "FR_Ch8": 21568,
            "GPS Altitude": 54031,
            "GPS Latitude": 54031,
            "GPS Longitude": 54031,
            "GPS Speed": 54031,
            "GPS_Fix": 54031,
            "GPS_InlineAcc": 54031,
            "GPS_LateralAcc": 54031,
            "GPS_Position_Accuracy": 54031,
            "GPS_Satellites": 54031,
            "GPS_Velocity_Accuracy": 54031,
            "GPS_Yaw_Rate": 54031,
            "GPS_pDOP": 54031,
            "Gear": 10806,
            "InlineAcc": 108060,
            "IntakeAirT": 100396,
            "LF_Shock_Pot": 1080650,
            "LR_Shock_Pot": 1080650,
            "Lambda": 100395,
            "LateralAcc": 108060,
            "LoggerTemp": 2161,
            "Luminosity": 2161,
            "MAP": 100396,
            "OilTemp": 10806,
            "PPS": 54029,
            "PitchRate": 108060,
            "Predictive Time": 963,
            "Prev Lap Diff": 15,
            "RF_Shock_Pot": 1080650,
            "RL_Ch1": 21568,
            "RL_Ch2": 21568,
            "RL_Ch3": 21568,
            "RL_Ch4": 21568,
            "RL_Ch5": 21568,
            "RL_Ch6": 21568,
            "RL_Ch7": 21568,
            "RL_Ch8": 21568,
            "RPM": 54029,
            "RR_Ch1": 21561,
            "RR_Ch2": 21561,
            "RR_Ch3": 21561,
            "RR_Ch4": 21561,
            "RR_Ch5": 21561,
            "RR_Ch6": 21561,
            "RR_Ch7": 21561,
            "RR_Ch8": 21561,
            "RR_Shock_Pot": 1080650,
            "Ref Lap Diff": 15,
            "RollRate": 108060,
            "SpeedAverage": 36024,
            "SteerAngle": 21614,
            "TPMS_ALM_LF": 10807,
            "TPMS_ALM_LR": 10807,
            "TPMS_ALM_RF": 10807,
            "TPMS_ALM_RR": 10807,
            "TPMS_Press_LF": 10807,
            "TPMS_Press_LR": 10807,
            "TPMS_Press_RF": 10807,
            "TPMS_Press_RR": 10807,
            "TPMS_Temp_LF": 10807,
            "TPMS_Temp_LR": 10807,
            "TPMS_Temp_RF": 10807,
            "TPMS_Temp_RR": 10807,
            "TPMS_Volt_LF": 10807,
            "TPMS_Volt_LR": 10807,
            "TPMS_Volt_RF": 10807,
            "TPMS_Volt_RR": 10807,
            "TPS": 100396,
            "VerticalAcc": 108060,
            "WheelSpdFL": 36024,
            "WheelSpdFR": 36024,
            "WheelSpdRL": 36024,
            "WheelSpdRR": 36024,
            "YawRate": 108060,
        }

        for channel_name, expected_count in expected_row_counts.items():
            self.assertIn(channel_name, log.channels, f"Channel '{channel_name}' not found")
            actual_count = len(log.channels[channel_name])
            self.assertEqual(
                actual_count,
                expected_count,
                f"Channel '{channel_name}' row count mismatch: expected {expected_count}, got {actual_count}",
            )

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_channel_first_last_values(self, name, file_path):
        """Test that channels have expected first and last values (all channels)."""
        log = aim_xrk(str(file_path), progress=None)

        # Test all channels with first and last values from test_data.md
        test_cases = [
            ("AmbientTemp", 6.000, 6.000, 0.1),
            ("Baro", 0.940, 0.950, 0.001),
            ("Best Run Diff", -12290.0, 36591.0, 1.0),
            ("Best Today Diff", -12290.0, -12290.0, 1.0),
            ("BrakePress", 9.142, 18.284, 0.1),
            ("BrakeSw", 1.000, 1.000, 0.001),
            ("CAT1", 274.200, 542.600, 1.0),
            ("CH", 1.000, 1.000, 0.001),
            ("ClutchSw", 1.000, 1.000, 0.001),
            ("ECT", 89.000, 91.000, 0.1),
            ("External Voltage", 14.464, 14.480, 0.1),
            ("FL_Ch1", 45.900, 46.500, 0.1),
            ("FL_Ch2", 46.100, 51.400, 0.1),
            ("FL_Ch3", 45.400, 50.300, 0.1),
            ("FL_Ch4", 45.500, 48.900, 0.1),
            ("FL_Ch5", 46.000, 54.300, 0.1),
            ("FL_Ch6", 45.300, 50.000, 0.1),
            ("FL_Ch7", 44.000, 54.200, 0.1),
            ("FL_Ch8", 51.100, 42.600, 0.1),
            ("FR_Ch1", 57.800, 39.200, 0.1),
            ("FR_Ch2", 54.100, 55.400, 0.1),
            ("FR_Ch3", 54.600, 51.800, 0.1),
            ("FR_Ch4", 55.300, 51.700, 0.1),
            ("FR_Ch5", 57.300, 49.000, 0.1),
            ("FR_Ch6", 55.400, 49.300, 0.1),
            ("FR_Ch7", 57.100, 51.000, 0.1),
            ("FR_Ch8", 55.500, 48.400, 0.1),
            ("GPS Altitude", 619.999, 625.256, 1.0),
            ("GPS Latitude", 35.374, 35.370, 0.001),
            ("GPS Longitude", 138.930, 138.925, 0.001),
            ("GPS Speed", 0.030, 0.000, 0.001),
            ("GPS_Fix", 3.0, 3.0, 0.001),
            ("GPS_InlineAcc", 0.000, -0.057, 0.01),
            ("GPS_LateralAcc", 0.000, 0.000, 0.01),
            ("GPS_Position_Accuracy", 0.460, 0.280, 0.01),
            ("GPS_Satellites", 12.0, 14.0, 0.1),
            ("GPS_Velocity_Accuracy", 0.110, 0.080, 0.01),
            (
                "GPS_Yaw_Rate",
                0.000,
                947.433,
                1.0,
            ),  # High value at end due to GPS noise when stopped
            ("GPS_pDOP", 1.40, 1.39, 0.01),
            ("Gear", 0.000, 0.000, 0.001),
            ("InlineAcc", -0.027, -0.003, 0.001),
            ("IntakeAirT", 46.000, 19.000, 0.1),
            ("LF_Shock_Pot", -0.914, -0.151, 0.001),
            ("LR_Shock_Pot", 1.221, -2.441, 0.001),
            ("Lambda", 0.995, 0.995, 0.001),
            ("LateralAcc", -0.008, 0.032, 0.001),
            ("LoggerTemp", 35.094, 37.250, 0.1),
            ("Luminosity", 14.242, 15.539, 0.01),
            ("MAP", 0.310, 0.280, 0.001),
            ("OilTemp", 93.000, 95.000, 0.1),
            ("PPS", 0.000, 0.000, 0.001),
            ("PitchRate", 0.105, 0.063, 0.001),
            ("Predictive Time", -12290.0, 161647.0, 1.0),
            ("Prev Lap Diff", -12290.0, -12290.0, 1.0),
            ("RF_Shock_Pot", 0.916, -2.898, 0.001),
            ("RL_Ch1", 41.200, 48.200, 0.1),
            ("RL_Ch2", 39.500, 47.100, 0.1),
            ("RL_Ch3", 38.300, 45.800, 0.1),
            ("RL_Ch4", 38.700, 47.300, 0.1),
            ("RL_Ch5", 38.900, 48.200, 0.1),
            ("RL_Ch6", 38.400, 45.900, 0.1),
            ("RL_Ch7", 38.000, 44.300, 0.1),
            ("RL_Ch8", 33.700, 38.700, 0.1),
            ("RPM", 712.000, 732.000, 1.0),
            ("RR_Ch1", 32.900, 37.400, 0.1),
            ("RR_Ch2", 37.900, 45.200, 0.1),
            ("RR_Ch3", 38.500, 46.600, 0.1),
            ("RR_Ch4", 39.600, 50.400, 0.1),
            ("RR_Ch5", 37.500, 48.700, 0.1),
            ("RR_Ch6", 38.000, 49.700, 0.1),
            ("RR_Ch7", 38.000, 49.500, 0.1),
            ("RR_Ch8", 39.600, 49.400, 0.1),
            ("RR_Shock_Pot", -1.067, 2.287, 0.001),
            ("Ref Lap Diff", -12290.0, -12290.0, 1.0),
            ("RollRate", -0.136, -0.059, 0.001),
            ("SpeedAverage", 0.000, 0.000, 0.001),
            ("SteerAngle", -3.600, -31.600, 0.1),
            ("TPMS_ALM_LF", 0.000, 0.000, 0.001),
            ("TPMS_ALM_LR", 0.000, 0.000, 0.001),
            ("TPMS_ALM_RF", 0.000, 0.000, 0.001),
            ("TPMS_ALM_RR", 0.000, 0.000, 0.001),
            ("TPMS_Press_LF", 1.820, 1.850, 0.01),
            ("TPMS_Press_LR", 1.820, 1.880, 0.01),
            ("TPMS_Press_RF", 1.820, 1.850, 0.01),
            ("TPMS_Press_RR", 1.790, 1.880, 0.01),
            ("TPMS_Temp_LF", 62.000, 55.000, 0.1),
            ("TPMS_Temp_LR", 49.000, 52.000, 0.1),
            ("TPMS_Temp_RF", 59.000, 51.000, 0.1),
            ("TPMS_Temp_RR", 48.000, 51.000, 0.1),
            ("TPMS_Volt_LF", 2900.0, 3000.0, 100.0),
            ("TPMS_Volt_LR", 2900.0, 3000.0, 100.0),
            ("TPMS_Volt_RF", 2900.0, 3000.0, 100.0),
            ("TPMS_Volt_RR", 2900.0, 3000.0, 100.0),
            ("TPS", 16.450, 15.980, 0.1),
            ("VerticalAcc", -1.000, -1.001, 0.001),
            ("WheelSpdFL", 0.000, 0.000, 0.001),
            ("WheelSpdFR", 0.000, 0.000, 0.001),
            ("WheelSpdRL", 0.000, 0.000, 0.001),
            ("WheelSpdRR", 0.000, 0.000, 0.001),
            ("YawRate", 0.012, 0.034, 0.001),
        ]

        # Ensure we're testing all channels
        test_channel_names = {channel_name for channel_name, _, _, _ in test_cases}
        actual_channel_names = set(log.channels.keys())
        self.assertEqual(
            test_channel_names,
            actual_channel_names,
            f"Test cases don't cover all channels.\nMissing: {actual_channel_names - test_channel_names}\nExtra: {test_channel_names - actual_channel_names}",
        )

        for channel_name, expected_first, expected_last, tolerance in test_cases:
            self.assertIn(channel_name, log.channels, f"Channel '{channel_name}' not found")
            channel_table = log.channels[channel_name]
            data_column = channel_table.column(channel_name)

            first_value = data_column[0].as_py()
            last_value = data_column[-1].as_py()

            self.assertAlmostEqual(
                first_value,
                expected_first,
                delta=tolerance,
                msg=f"Channel '{channel_name}' first value mismatch",
            )
            self.assertAlmostEqual(
                last_value,
                expected_last,
                delta=tolerance,
                msg=f"Channel '{channel_name}' last value mismatch",
            )

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_channel_metadata(self, name, file_path):
        """Test that channels have correct metadata (all channels)."""
        log = aim_xrk(str(file_path), progress=None)

        # Test all channels with metadata from test_data.md
        # Format: (channel_name, units, dec_pts, interpolate)
        test_cases = [
            ("AmbientTemp", "C", "1", "True"),
            ("Baro", "bar", "2", "True"),
            ("Best Run Diff", "ms", "0", "False"),
            ("Best Today Diff", "ms", "0", "False"),
            ("BrakePress", "bar", "2", "True"),
            ("BrakeSw", "", "0", "True"),
            ("CAT1", "C", "1", "True"),
            ("CH", "", "0", "True"),
            ("ClutchSw", "", "0", "True"),
            ("ECT", "C", "1", "True"),
            ("External Voltage", "V", "1", "True"),
            ("FL_Ch1", "C", "1", "True"),
            ("FL_Ch2", "C", "1", "True"),
            ("FL_Ch3", "C", "1", "True"),
            ("FL_Ch4", "C", "1", "True"),
            ("FL_Ch5", "C", "1", "True"),
            ("FL_Ch6", "C", "1", "True"),
            ("FL_Ch7", "C", "1", "True"),
            ("FL_Ch8", "C", "1", "True"),
            ("FR_Ch1", "C", "1", "True"),
            ("FR_Ch2", "C", "1", "True"),
            ("FR_Ch3", "C", "1", "True"),
            ("FR_Ch4", "C", "1", "True"),
            ("FR_Ch5", "C", "1", "True"),
            ("FR_Ch6", "C", "1", "True"),
            ("FR_Ch7", "C", "1", "True"),
            ("FR_Ch8", "C", "1", "True"),
            ("GPS Altitude", "m", "1", "True"),
            ("GPS Latitude", "deg", "4", "True"),
            ("GPS Longitude", "deg", "4", "True"),
            ("GPS Speed", "m/s", "1", "True"),
            ("GPS_Fix", "", "0", "False"),
            ("GPS_InlineAcc", "g", "2", "True"),
            ("GPS_LateralAcc", "g", "2", "True"),
            ("GPS_Position_Accuracy", "m", "2", "True"),
            ("GPS_Satellites", "", "0", "False"),
            ("GPS_Velocity_Accuracy", "m/s", "2", "True"),
            ("GPS_Yaw_Rate", "deg/s", "1", "True"),
            ("GPS_pDOP", "", "2", "False"),
            ("Gear", "gear", "0", "False"),
            ("InlineAcc", "g", "2", "True"),
            ("IntakeAirT", "C", "1", "True"),
            ("LF_Shock_Pot", "mm", "0", "True"),
            ("LR_Shock_Pot", "mm", "0", "True"),
            ("Lambda", "lambda", "2", "True"),
            ("LateralAcc", "g", "2", "True"),
            ("LoggerTemp", "C", "1", "True"),
            ("Luminosity", "%", "2", "True"),
            ("MAP", "bar", "2", "True"),
            ("OilTemp", "C", "1", "True"),
            ("PPS", "%", "2", "True"),
            ("PitchRate", "deg/s", "1", "True"),
            ("Predictive Time", "ms", "0", "False"),
            ("Prev Lap Diff", "ms", "0", "False"),
            ("RF_Shock_Pot", "mm", "0", "True"),
            ("RL_Ch1", "C", "1", "True"),
            ("RL_Ch2", "C", "1", "True"),
            ("RL_Ch3", "C", "1", "True"),
            ("RL_Ch4", "C", "1", "True"),
            ("RL_Ch5", "C", "1", "True"),
            ("RL_Ch6", "C", "1", "True"),
            ("RL_Ch7", "C", "1", "True"),
            ("RL_Ch8", "C", "1", "True"),
            ("RPM", "rpm", "0", "True"),
            ("RR_Ch1", "C", "1", "True"),
            ("RR_Ch2", "C", "1", "True"),
            ("RR_Ch3", "C", "1", "True"),
            ("RR_Ch4", "C", "1", "True"),
            ("RR_Ch5", "C", "1", "True"),
            ("RR_Ch6", "C", "1", "True"),
            ("RR_Ch7", "C", "1", "True"),
            ("RR_Ch8", "C", "1", "True"),
            ("RR_Shock_Pot", "mm", "0", "True"),
            ("Ref Lap Diff", "ms", "0", "False"),
            ("RollRate", "deg/s", "1", "True"),
            ("SpeedAverage", "km/h", "0", "True"),
            ("SteerAngle", "deg", "1", "True"),
            ("TPMS_ALM_LF", "", "0", "True"),
            ("TPMS_ALM_LR", "", "0", "True"),
            ("TPMS_ALM_RF", "", "0", "True"),
            ("TPMS_ALM_RR", "", "0", "True"),
            ("TPMS_Press_LF", "bar", "2", "True"),
            ("TPMS_Press_LR", "bar", "2", "True"),
            ("TPMS_Press_RF", "bar", "2", "True"),
            ("TPMS_Press_RR", "bar", "2", "True"),
            ("TPMS_Temp_LF", "C", "1", "True"),
            ("TPMS_Temp_LR", "C", "1", "True"),
            ("TPMS_Temp_RF", "C", "1", "True"),
            ("TPMS_Temp_RR", "C", "1", "True"),
            ("TPMS_Volt_LF", "mV", "1", "True"),
            ("TPMS_Volt_LR", "mV", "1", "True"),
            ("TPMS_Volt_RF", "mV", "1", "True"),
            ("TPMS_Volt_RR", "mV", "1", "True"),
            ("TPS", "%", "2", "True"),
            ("VerticalAcc", "g", "2", "True"),
            ("WheelSpdFL", "km/h", "0", "True"),
            ("WheelSpdFR", "km/h", "0", "True"),
            ("WheelSpdRL", "km/h", "0", "True"),
            ("WheelSpdRR", "km/h", "0", "True"),
            ("YawRate", "deg/s", "1", "True"),
        ]

        # Ensure we're testing all channels
        test_channel_names = {channel_name for channel_name, _, _, _ in test_cases}
        actual_channel_names = set(log.channels.keys())
        self.assertEqual(
            test_channel_names,
            actual_channel_names,
            f"Test cases don't cover all channels.\nMissing: {actual_channel_names - test_channel_names}\nExtra: {test_channel_names - actual_channel_names}",
        )

        for channel_name, expected_units, expected_dec_pts, expected_interpolate in test_cases:
            self.assertIn(channel_name, log.channels, f"Channel '{channel_name}' not found")
            meta = ChannelMetadata.from_channel_table(log.channels[channel_name])

            self.assertEqual(meta.units, expected_units, f"Channel '{channel_name}' units mismatch")
            self.assertEqual(
                meta.dec_pts,
                int(expected_dec_pts),
                f"Channel '{channel_name}' dec_pts mismatch",
            )
            self.assertEqual(
                meta.interpolate,
                expected_interpolate == "True",
                f"Channel '{channel_name}' interpolate mismatch",
            )

    @parameterized.expand(FILE_VARIANTS)
    def test_86_xrk_channel_function_metadata(self, name, file_path):
        """Test that channels have correct function metadata."""
        log = aim_xrk(str(file_path), progress=None)

        # Format: (channel_name, expected_function)
        # These values are verified against RS3 channel properties.
        test_cases = [
            ("ECT", "Temperature"),
            ("CAT1", "Exhaust Temperature"),
            ("AmbientTemp", "Temperature"),
            ("OilTemp", "Oil Temperature"),
            ("IntakeAirT", "Intake Air Temperature"),
            ("LoggerTemp", "Device Temperature"),
            ("TPMS_Temp_LF", "Temperature"),
            ("FL_Ch1", "Temperature"),
            ("LateralAcc", "Lateral Acceleration"),
            ("InlineAcc", "Inline Acceleration"),
            ("VerticalAcc", "Vertical Acceleration"),
            ("YawRate", "Yaw Rate"),
            ("PitchRate", "Pitch Rate"),
            ("RollRate", "Roll Rate"),
            ("RPM", "Engine RPM"),
            ("Gear", "Gear"),
            ("BrakePress", "Brake Circuit Pressure"),
            ("WheelSpdFL", "Rear Wheel Speed"),
            ("SteerAngle", "Steering Angle"),
            ("Lambda", "Lambda"),
            ("TPS", "Percent"),
            ("External Voltage", "Battery Voltage"),
            ("MAP", "Pressure"),
            ("LF_Shock_Pot", "LF Shock Position"),
            ("Baro", "Pressure"),
            ("Luminosity", "Device Brightness"),
            ("SpeedAverage", "Vehicle Speed"),
            ("TPMS_Press_LF", "Pressure"),
            ("PPS", "Percentage Throttle Load"),
            ("ClutchSw", "Number"),
            ("BrakeSw", "Number"),
            ("CH", "Number"),
            ("TPMS_Volt_LF", "Voltage"),
            ("TPMS_ALM_LF", "Number"),
            ("Predictive Time", ""),
            # GPS-derived channels have empty function
            ("GPS Speed", ""),
            ("GPS Latitude", ""),
            ("GPS Longitude", ""),
            ("GPS Altitude", ""),
        ]

        for channel_name, expected_function in test_cases:
            self.assertIn(channel_name, log.channels, f"Channel '{channel_name}' not found")
            meta = ChannelMetadata.from_channel_table(log.channels[channel_name])
            self.assertEqual(
                meta.function,
                expected_function,
                f"Channel '{channel_name}' function: got {meta.function!r}, expected {expected_function!r}",
            )


def _rust_backend_available() -> bool:
    """Check if the Rust backend extension is importable."""
    try:
        import libxrk._aim_xrk_rs  # noqa: F401

        return True
    except ImportError:
        return False


@unittest.skipUnless(_rust_backend_available(), "Rust backend not available")
class Test86CrossBackend(unittest.TestCase):
    """Cross-backend comparison for the 86 XRK file."""

    rust_log: ClassVar[LogFile]
    cython_log: ClassVar[LogFile]

    @classmethod
    def setUpClass(cls) -> None:
        from libxrk._aim_xrk_rs import aim_xrk as rust_aim_xrk
        from libxrk.aim_xrk import aim_xrk as cython_aim_xrk

        cls.rust_log = rust_aim_xrk(str(XRK_86_FILE))
        cls.cython_log = cython_aim_xrk(str(XRK_86_FILE))

    def test_channel_names_match(self) -> None:
        """Both backends should produce the same set of channel names."""
        self.assertEqual(
            set(self.rust_log.channels.keys()),
            set(self.cython_log.channels.keys()),
        )

    def test_channel_sample_counts_match(self) -> None:
        """Per-channel sample counts should match between backends."""
        for name in self.rust_log.channels:
            rust_count = len(self.rust_log.channels[name])
            cython_count = len(self.cython_log.channels[name])
            self.assertEqual(
                rust_count,
                cython_count,
                f"Channel {name!r}: rust={rust_count} != cython={cython_count}",
            )

    def test_channel_types_match(self) -> None:
        """Arrow types should match between Rust and Cython for all channels."""
        for name in self.rust_log.channels:
            rust_type = self.rust_log.channels[name].schema.field(name).type
            cython_type = self.cython_log.channels[name].schema.field(name).type
            self.assertEqual(
                rust_type,
                cython_type,
                f"Channel {name!r}: rust type={rust_type} != cython type={cython_type}",
            )

    def test_non_gps_channel_values_exact_match(self) -> None:
        """Non-GPS CHS channels should produce identical timecodes and values."""
        gps_prefixes = ("GPS ", "GPS_")
        for name in self.rust_log.channels:
            if any(name.startswith(p) for p in gps_prefixes):
                continue
            rust_tc = self.rust_log.channels[name].column("timecodes").to_numpy()
            cython_tc = self.cython_log.channels[name].column("timecodes").to_numpy()
            np.testing.assert_array_equal(rust_tc, cython_tc, err_msg=f"{name} timecodes")
            rust_vals = self.rust_log.channels[name].column(name).to_numpy(zero_copy_only=False)
            cython_vals = self.cython_log.channels[name].column(name).to_numpy(zero_copy_only=False)
            np.testing.assert_array_equal(rust_vals, cython_vals, err_msg=f"{name} values")

    def test_gps_channel_values_close(self) -> None:
        """GPS channel values should be close between backends."""
        for name in self.rust_log.channels:
            if not (name.startswith("GPS ") or name.startswith("GPS_")):
                continue
            rust_vals = self.rust_log.channels[name].column(name).to_numpy(zero_copy_only=False)
            cython_vals = self.cython_log.channels[name].column(name).to_numpy(zero_copy_only=False)
            np.testing.assert_allclose(
                rust_vals, cython_vals, rtol=1e-5, atol=1e-10, err_msg=f"{name}"
            )

    def test_metadata_match(self) -> None:
        """Key metadata should match between backends."""
        for key in (
            "Logger ID",
            "Logger Model ID",
            "Venue",
            "GPS Receiver",
            "Vehicle Electronics Type",
        ):
            rust_val = self.rust_log.metadata.get(key)
            cython_val = self.cython_log.metadata.get(key)
            self.assertEqual(
                rust_val,
                cython_val,
                f"Metadata {key!r}: rust={rust_val!r} != cython={cython_val!r}",
            )

    def test_lap_data_match(self) -> None:
        """Lap counts and timing should match between backends."""
        self.assertEqual(self.rust_log.laps.num_rows, self.cython_log.laps.num_rows)
        rust_starts = self.rust_log.laps.column("start_time").to_pylist()
        cython_starts = self.cython_log.laps.column("start_time").to_pylist()
        self.assertEqual(rust_starts, cython_starts)
        rust_ends = self.rust_log.laps.column("end_time").to_pylist()
        cython_ends = self.cython_log.laps.column("end_time").to_pylist()
        self.assertEqual(rust_ends, cython_ends)

    def test_channel_metadata_match(self) -> None:
        """Channel metadata (units, dec_pts, interpolate, function) should match between backends."""
        for name in self.rust_log.channels:
            rust_meta = ChannelMetadata.from_channel_table(self.rust_log.channels[name])
            cython_meta = ChannelMetadata.from_channel_table(self.cython_log.channels[name])
            self.assertEqual(rust_meta.units, cython_meta.units, f"Channel {name!r} units mismatch")
            self.assertEqual(
                rust_meta.dec_pts, cython_meta.dec_pts, f"Channel {name!r} dec_pts mismatch"
            )
            self.assertEqual(
                rust_meta.interpolate,
                cython_meta.interpolate,
                f"Channel {name!r} interpolate mismatch",
            )
            self.assertEqual(
                rust_meta.function, cython_meta.function, f"Channel {name!r} function mismatch"
            )

    def test_expansion_device_metadata_match(self) -> None:
        """Expansion device metadata should match between backends."""
        rust_devices = self.rust_log.metadata.get("Expansion Devices", [])
        cython_devices = self.cython_log.metadata.get("Expansion Devices", [])
        self.assertEqual(len(rust_devices), len(cython_devices))
        for i, (r, c) in enumerate(zip(rust_devices, cython_devices)):
            for key in ("Bus Unit", "Bus Type", "Manufacturer", "Model", "Logger ID", "Model ID"):
                self.assertEqual(
                    r.get(key),
                    c.get(key),
                    f"Device {i} {key!r}: rust={r.get(key)!r} != cython={c.get(key)!r}",
                )


if __name__ == "__main__":
    unittest.main()
