"""Tests for the generated ``arautopilot.shared.modbus_register_map``.

This module is regenerated from ``firmware/ar_autopilot_v1/modbus_registers.yaml``
by ``tools/gen_modbus_registers.py``. The tests below are the safety net that
flags drift: if anyone edits the YAML, the generated Python module must be
regenerated, and these tests verify the result is structurally sound.

The tests deliberately do NOT pin the *content* of every register (which would
just duplicate the YAML); they pin invariants that must always hold.
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import pytest

from arautopilot.shared import modbus_register_map as m

REPO_ROOT = Path(__file__).resolve().parents[2]
YAML_PATH = REPO_ROOT / "firmware/ar_autopilot_v1/modbus_registers.yaml"
GEN_SCRIPT = REPO_ROOT / "tools/gen_modbus_registers.py"
HEADER_PATH = (
    REPO_ROOT / "firmware/ar_autopilot_v1/src/protocols/modbus_registers.h"
)
PY_PATH = REPO_ROOT / "arautopilot/shared/modbus_register_map.py"


# ----------------------------------------------------------------------------
# Schema / connection settings
# ----------------------------------------------------------------------------


def test_schema_version_is_semver_string() -> None:
    parts = m.MAP_SCHEMA_VERSION.split(".")
    assert len(parts) == 3, f"schema version must be semver: {m.MAP_SCHEMA_VERSION}"
    for p in parts:
        assert p.isdigit(), f"non-numeric semver component: {p}"


def test_slave_id_in_valid_range() -> None:
    # Modbus RTU spec: slave addresses 1..247.
    assert 1 <= m.SLAVE_ID <= 247


def test_baudrate_is_supported_value() -> None:
    assert m.BAUDRATE in {9600, 19200, 38400, 57600, 115200}


def test_serial_framing_is_8N1() -> None:
    assert m.DATA_BITS == 8
    assert m.PARITY == "N"
    assert m.STOP_BITS == 1


# ----------------------------------------------------------------------------
# Register groups
# ----------------------------------------------------------------------------


@pytest.mark.parametrize("group", ["DISCRETES", "COILS", "INPUTS", "HOLDINGS"])
def test_groups_are_nonempty(group: str) -> None:
    assert len(getattr(m, group)) > 0, f"group {group} is empty"


@pytest.mark.parametrize("group", ["DISCRETES", "COILS", "INPUTS", "HOLDINGS"])
def test_addresses_are_unique_within_group(group: str) -> None:
    regs = getattr(m, group)
    addrs = [r.addr for r in regs.values()]
    assert len(addrs) == len(set(addrs)), f"duplicate addresses in {group}"


@pytest.mark.parametrize("group", ["DISCRETES", "COILS", "INPUTS", "HOLDINGS"])
def test_names_match_dict_keys(group: str) -> None:
    """The dict key and the Reg.name field must match exactly."""
    regs = getattr(m, group)
    for key, reg in regs.items():
        assert key == reg.name, f"{group}[{key!r}].name mismatch: {reg.name!r}"


@pytest.mark.parametrize("group", ["DISCRETES", "COILS", "INPUTS", "HOLDINGS"])
def test_addresses_in_modbus_range(group: str) -> None:
    """Modbus addresses are uint16. Anything past 65535 is invalid."""
    for r in getattr(m, group).values():
        assert 0 <= r.addr <= 0xFFFF, f"out-of-range addr in {group}: {r}"


# ----------------------------------------------------------------------------
# Spot-check the critical registers
# ----------------------------------------------------------------------------


def test_pilot_engaged_discrete_exists() -> None:
    assert "PILOT_ENGAGED" in m.DISCRETES


def test_disengage_button_discrete_exists() -> None:
    assert "DI_DISENGAGE_BUTTON" in m.DISCRETES


def test_mode_request_holding_exists_and_has_no_scale() -> None:
    r = m.HOLDINGS["MODE_REQUEST"]
    assert r.scale == 1.0
    assert r.offset == 0.0


def test_heading_setpoint_uses_centideg_scale() -> None:
    r = m.HOLDINGS["HEADING_SETPOINT_X100"]
    assert r.scale == pytest.approx(0.01)
    assert r.unit == "deg"


def test_disengage_command_coil_exists() -> None:
    assert "CMD_DISENGAGE_REQUEST" in m.COILS


def test_rudder_angle_input_uses_centideg_scale() -> None:
    r = m.INPUTS["RUDDER_ANGLE_DEG_X100"]
    assert r.scale == pytest.approx(0.01)
    assert r.unit == "deg"


def test_alarms_have_bit_neighbourhood() -> None:
    """Every ALARM_* discrete must be at addr >= 16 (reserved alarm range)."""
    for name, reg in m.DISCRETES.items():
        if name.startswith("ALARM_") or name == "ANY_ALARM_ACTIVE":
            assert reg.addr >= 16, f"{name} should live in the alarm range"


# ----------------------------------------------------------------------------
# Drift detection: regenerated files must be in sync with the YAML
# ----------------------------------------------------------------------------


def test_generated_files_are_in_sync_with_yaml() -> None:
    """If this test fails, run: python tools/gen_modbus_registers.py"""
    result = subprocess.run(
        [sys.executable, str(GEN_SCRIPT), "--check"],
        capture_output=True,
        text=True,
        cwd=str(REPO_ROOT),
    )
    assert result.returncode == 0, (
        f"generated Modbus map is OUT-OF-DATE. Regenerate with:\n"
        f"  python tools/gen_modbus_registers.py\n\n"
        f"stderr:\n{result.stderr}"
    )


def test_header_file_exists() -> None:
    assert HEADER_PATH.exists(), (
        f"firmware header not generated: {HEADER_PATH}"
    )


def test_generated_header_mentions_every_register_name() -> None:
    header = HEADER_PATH.read_text(encoding="utf-8")
    for group_dict in m.ALL_GROUPS.values():
        for name in group_dict:
            assert name in header, (
                f"register {name} missing from generated header"
            )
