"""Modbus register utility helpers -- Sprint 9 hardening.

Provides value scaling, bounds checking, and safe uint16 clamping for
register writes. Used by the Studio and CLI tools to validate writes
before they are sent on the wire.
"""

from __future__ import annotations

import math


UINT16_MAX = 0xFFFF
INT16_MIN = -32768
INT16_MAX = 32767


def scale_to_raw(physical: float, scale: float, offset: float = 0.0) -> int:
    """Convert a physical value to a Modbus raw uint16 (unsigned).

    ``raw = round((physical - offset) / scale)``

    Raises ``ValueError`` if the result does not fit in uint16.
    """
    if scale == 0.0:
        raise ValueError("scale must not be 0")
    if not math.isfinite(physical):
        raise ValueError(f"physical value must be finite, got {physical!r}")
    raw = round((physical - offset) / scale)
    if raw < 0 or raw > UINT16_MAX:
        raise ValueError(
            f"scaled raw value {raw} for physical={physical} out of uint16 range [0, {UINT16_MAX}]"
        )
    return raw


def raw_to_scaled(raw: int, scale: float, offset: float = 0.0) -> float:
    """Convert a raw Modbus uint16 to a physical value."""
    return raw * scale + offset


def scale_to_raw_signed(physical: float, scale: float, offset: float = 0.0) -> int:
    """Convert a physical value to a signed int16 stored as uint16 (two's complement).

    Raises ``ValueError`` if the result does not fit in int16.
    """
    if scale == 0.0:
        raise ValueError("scale must not be 0")
    if not math.isfinite(physical):
        raise ValueError(f"physical value must be finite, got {physical!r}")
    raw = round((physical - offset) / scale)
    if raw < INT16_MIN or raw > INT16_MAX:
        raise ValueError(
            f"signed raw {raw} for physical={physical} out of int16 range"
        )
    # Return as uint16 two's-complement
    return int(raw) & 0xFFFF


def raw_signed_to_scaled(raw: int, scale: float, offset: float = 0.0) -> float:
    """Convert a two's-complement uint16 raw to a signed physical value."""
    if raw > INT16_MAX:
        raw -= 0x10000
    return raw * scale + offset


def clamp_uint16(value: int) -> int:
    """Clamp an integer to [0, 65535]."""
    return max(0, min(UINT16_MAX, value))


def validate_holding_write(reg_name: str, raw_value: int) -> None:
    """Raise ``ValueError`` if ``raw_value`` is outside uint16 range.

    This is a wire-level guard; the caller is responsible for applying any
    sign extension or scale conversion before calling this function.
    """
    if not isinstance(raw_value, int):
        raise TypeError(f"Holding write for {reg_name}: value must be int, got {type(raw_value)}")
    if raw_value < 0 or raw_value > UINT16_MAX:
        raise ValueError(
            f"Holding register '{reg_name}': raw value {raw_value} out of uint16 range "
            f"[0, {UINT16_MAX}]"
        )


def heading_deg_to_raw(heading_deg: float) -> int:
    """Convert a compass heading in degrees to the HEADING_SETPOINT_X100 raw value.

    Normalises to [0, 360) before scaling.  Raises ``ValueError`` for non-finite input.
    """
    if not math.isfinite(heading_deg):
        raise ValueError(f"heading_deg must be finite, got {heading_deg!r}")
    normalised = heading_deg % 360.0
    return scale_to_raw(normalised, scale=0.01)


def raw_to_heading_deg(raw: int) -> float:
    """Convert a HEADING_X100 raw value to degrees."""
    return raw * 0.01


def rudder_deg_to_raw_signed(rudder_deg: float) -> int:
    """Convert a rudder angle in degrees (±35) to a signed uint16 raw value × 100."""
    return scale_to_raw_signed(rudder_deg, scale=0.01)
