"""Tests for Modbus register utility helpers -- Sprint 9."""

from __future__ import annotations

import math

import pytest

from arautopilot.shared.modbus_utils import (
    UINT16_MAX,
    INT16_MAX,
    INT16_MIN,
    clamp_uint16,
    heading_deg_to_raw,
    raw_signed_to_scaled,
    raw_to_heading_deg,
    raw_to_scaled,
    rudder_deg_to_raw_signed,
    scale_to_raw,
    scale_to_raw_signed,
    validate_holding_write,
)


class TestScaleToRaw:
    def test_basic_heading(self):
        assert scale_to_raw(45.0, scale=0.01) == 4500

    def test_zero(self):
        assert scale_to_raw(0.0, scale=0.01) == 0

    def test_max_uint16(self):
        assert scale_to_raw(655.35, scale=0.01) == UINT16_MAX

    def test_out_of_range_raises(self):
        with pytest.raises(ValueError, match="uint16"):
            scale_to_raw(700.0, scale=0.01)

    def test_negative_raises(self):
        with pytest.raises(ValueError, match="uint16"):
            scale_to_raw(-1.0, scale=0.01)

    def test_zero_scale_raises(self):
        with pytest.raises(ValueError, match="scale"):
            scale_to_raw(10.0, scale=0.0)

    def test_inf_raises(self):
        with pytest.raises(ValueError, match="finite"):
            scale_to_raw(math.inf, scale=0.01)

    def test_nan_raises(self):
        with pytest.raises(ValueError, match="finite"):
            scale_to_raw(math.nan, scale=0.01)

    def test_with_offset(self):
        # physical=10, offset=5, scale=1 → raw = (10-5)/1 = 5
        assert scale_to_raw(10.0, scale=1.0, offset=5.0) == 5


class TestRawToScaled:
    def test_basic(self):
        assert raw_to_scaled(4500, scale=0.01) == pytest.approx(45.0)

    def test_with_offset(self):
        assert raw_to_scaled(5, scale=1.0, offset=5.0) == pytest.approx(10.0)


class TestScaleToRawSigned:
    def test_positive_rudder(self):
        # +10.0 deg * 100 = 1000
        assert scale_to_raw_signed(10.0, scale=0.01) == 1000

    def test_negative_rudder(self):
        raw = scale_to_raw_signed(-10.0, scale=0.01)
        # -1000 in two's complement uint16 = 65536 - 1000 = 64536
        assert raw == (0xFFFF + 1) - 1000

    def test_zero(self):
        assert scale_to_raw_signed(0.0, scale=0.01) == 0

    def test_max_positive(self):
        assert scale_to_raw_signed(327.67, scale=0.01) == INT16_MAX

    def test_out_of_range_positive_raises(self):
        with pytest.raises(ValueError):
            scale_to_raw_signed(400.0, scale=0.01)

    def test_out_of_range_negative_raises(self):
        with pytest.raises(ValueError):
            scale_to_raw_signed(-400.0, scale=0.01)

    def test_nan_raises(self):
        with pytest.raises(ValueError, match="finite"):
            scale_to_raw_signed(math.nan, scale=0.01)


class TestRawSignedToScaled:
    def test_positive(self):
        assert raw_signed_to_scaled(1000, scale=0.01) == pytest.approx(10.0)

    def test_negative(self):
        raw = (0x10000 - 1000)  # -1000 in two's complement
        assert raw_signed_to_scaled(raw, scale=0.01) == pytest.approx(-10.0)

    def test_zero(self):
        assert raw_signed_to_scaled(0, scale=0.01) == pytest.approx(0.0)


class TestClampUint16:
    def test_in_range(self):
        assert clamp_uint16(1000) == 1000

    def test_below_zero(self):
        assert clamp_uint16(-5) == 0

    def test_above_max(self):
        assert clamp_uint16(70000) == UINT16_MAX

    def test_at_max(self):
        assert clamp_uint16(UINT16_MAX) == UINT16_MAX


class TestValidateHoldingWrite:
    def test_valid(self):
        validate_holding_write("HEADING_SETPOINT_X100", 4500)  # no exception

    def test_negative_raises(self):
        with pytest.raises(ValueError, match="uint16"):
            validate_holding_write("HEADING_SETPOINT_X100", -1)

    def test_over_max_raises(self):
        with pytest.raises(ValueError, match="uint16"):
            validate_holding_write("HEADING_SETPOINT_X100", UINT16_MAX + 1)

    def test_float_raises_type_error(self):
        with pytest.raises(TypeError):
            validate_holding_write("X", 45.0)

    def test_zero_valid(self):
        validate_holding_write("TEST", 0)

    def test_max_valid(self):
        validate_holding_write("TEST", UINT16_MAX)


class TestHeadingConversion:
    def test_zero(self):
        assert heading_deg_to_raw(0.0) == 0

    def test_45_deg(self):
        assert heading_deg_to_raw(45.0) == 4500

    def test_360_wraps_to_0(self):
        assert heading_deg_to_raw(360.0) == 0

    def test_over_360_wraps(self):
        assert heading_deg_to_raw(370.0) == heading_deg_to_raw(10.0)

    def test_negative_wraps(self):
        assert heading_deg_to_raw(-10.0) == heading_deg_to_raw(350.0)

    def test_inf_raises(self):
        with pytest.raises(ValueError):
            heading_deg_to_raw(math.inf)

    def test_roundtrip(self):
        for deg in [0.0, 45.0, 90.0, 135.0, 180.0, 270.0, 359.99]:
            raw = heading_deg_to_raw(deg)
            back = raw_to_heading_deg(raw)
            assert abs(back - deg) < 0.01


class TestRudderConversion:
    def test_positive(self):
        raw = rudder_deg_to_raw_signed(10.0)
        back = raw_signed_to_scaled(raw, scale=0.01)
        assert back == pytest.approx(10.0)

    def test_negative(self):
        raw = rudder_deg_to_raw_signed(-10.0)
        back = raw_signed_to_scaled(raw, scale=0.01)
        assert back == pytest.approx(-10.0)

    def test_zero(self):
        assert rudder_deg_to_raw_signed(0.0) == 0
