"""
MAX31865 RTD/PT100 driver for Sentinel.

The implementation uses the Linux SPI device through the ``spidev``
Python module. The conversion uses the IEC 60751 Callendar-Van Dusen
equation.
"""

from __future__ import annotations

import time
from typing import Protocol

from sentinel.sensors.base import TemperatureSensor


class MAX31865Error(RuntimeError):
    """Raised when the MAX31865 cannot provide a valid measurement."""


class SPIProtocol(Protocol):
    """Minimal SPI interface used by the MAX31865 driver."""

    max_speed_hz: int
    mode: int

    def xfer2(self, data: list[int]) -> list[int]:
        """Transfer bytes over SPI."""

    def close(self) -> None:
        """Close the SPI device."""


class MAX31865TemperatureSensor(TemperatureSensor):
    """
    Read a PT100 or PT1000 through a MAX31865 converter.
    """

    CONFIG_REGISTER = 0x00
    RTD_MSB_REGISTER = 0x01
    FAULT_STATUS_REGISTER = 0x07
    WRITE_FLAG = 0x80

    CONFIG_BIAS = 0x80
    CONFIG_ONE_SHOT = 0x20
    CONFIG_3WIRE = 0x10
    CONFIG_FAULT_CLEAR = 0x02
    CONFIG_FILTER_50HZ = 0x01

    A = 3.9083e-3
    B = -5.775e-7
    C = -4.183e-12

    def __init__(
        self,
        spi_bus: int = 0,
        spi_device: int = 0,
        wires: int = 3,
        rtd_nominal_ohms: float = 100.0,
        reference_resistor_ohms: float = 430.0,
        filter_hz: int = 50,
        spi: SPIProtocol | None = None,
    ) -> None:
        """
        Initialize the MAX31865.

        Args:
            spi_bus: Raspberry Pi SPI bus, normally 0.
            spi_device: Chip-select device, normally 0 for CE0.
            wires: RTD wiring mode: 2, 3 or 4.
            rtd_nominal_ohms: 100 for PT100, 1000 for PT1000.
            reference_resistor_ohms: Reference resistor fitted to the
                MAX31865 board. Common PT100 boards use 430 ohms.
            filter_hz: 50 or 60 Hz mains rejection.
            spi: Optional SPI-compatible object for testing.
        """
        if wires not in (2, 3, 4):
            raise ValueError(
                "MAX31865 wires must be 2, 3 or 4."
            )

        if filter_hz not in (50, 60):
            raise ValueError(
                "MAX31865 filter_hz must be 50 or 60."
            )

        self.wires = wires
        self.rtd_nominal_ohms = float(
            rtd_nominal_ohms
        )
        self.reference_resistor_ohms = float(
            reference_resistor_ohms
        )
        self.filter_hz = filter_hz
        self._owns_spi = spi is None

        if spi is None:
            try:
                import spidev  # type: ignore
            except ImportError as error:
                raise MAX31865Error(
                    "The Python package 'spidev' is required "
                    "for MAX31865 hardware access."
                ) from error

            device = spidev.SpiDev()
            device.open(
                spi_bus,
                spi_device,
            )
            device.max_speed_hz = 500_000
            device.mode = 0x01
            self.spi = device
        else:
            self.spi = spi
            self.spi.max_speed_hz = 500_000
            self.spi.mode = 0x01

    def close(self) -> None:
        """Close the SPI device when this instance owns it."""
        if self._owns_spi:
            self.spi.close()

    def _base_config(self) -> int:
        """Return persistent MAX31865 configuration bits."""
        config = 0

        if self.wires == 3:
            config |= self.CONFIG_3WIRE

        if self.filter_hz == 50:
            config |= self.CONFIG_FILTER_50HZ

        return config

    def _write_register(
        self,
        register: int,
        value: int,
    ) -> None:
        """Write one MAX31865 register."""
        self.spi.xfer2(
            [
                register | self.WRITE_FLAG,
                value & 0xFF,
            ]
        )

    def _read_registers(
        self,
        register: int,
        count: int,
    ) -> list[int]:
        """Read consecutive MAX31865 registers."""
        response = self.spi.xfer2(
            [register & 0x7F]
            + [0x00] * count
        )

        return response[1:]

    def read_resistance_ohms(self) -> float:
        """
        Perform one RTD conversion and return sensor resistance.
        """
        base = self._base_config()

        self._write_register(
            self.CONFIG_REGISTER,
            base
            | self.CONFIG_FAULT_CLEAR
            | self.CONFIG_BIAS,
        )

        time.sleep(0.010)

        self._write_register(
            self.CONFIG_REGISTER,
            base
            | self.CONFIG_BIAS
            | self.CONFIG_ONE_SHOT,
        )

        time.sleep(
            0.070
            if self.filter_hz == 50
            else 0.060
        )

        rtd_bytes = self._read_registers(
            self.RTD_MSB_REGISTER,
            2,
        )

        if len(rtd_bytes) != 2:
            raise MAX31865Error(
                "Incomplete MAX31865 RTD response."
            )

        raw_word = (
            (rtd_bytes[0] << 8)
            | rtd_bytes[1]
        )

        fault = raw_word & 0x01
        raw_rtd = raw_word >> 1

        if fault:
            fault_status = self._read_registers(
                self.FAULT_STATUS_REGISTER,
                1,
            )[0]

            raise MAX31865Error(
                "MAX31865 fault detected: "
                f"0x{fault_status:02X}"
            )

        resistance = (
            raw_rtd
            / 32768.0
            * self.reference_resistor_ohms
        )

        return resistance

    def _temperature_from_resistance(
        self,
        resistance_ohms: float,
    ) -> float:
        """
        Convert RTD resistance to degrees Celsius.

        Newton iteration is used so the same method works above and
        below 0 °C with the full Callendar-Van Dusen equation.
        """
        r0 = self.rtd_nominal_ohms

        temperature = (
            resistance_ohms / r0 - 1.0
        ) / self.A

        for _ in range(12):
            if temperature >= 0.0:
                predicted = r0 * (
                    1.0
                    + self.A * temperature
                    + self.B * temperature**2
                )

                derivative = r0 * (
                    self.A
                    + 2.0 * self.B * temperature
                )
            else:
                predicted = r0 * (
                    1.0
                    + self.A * temperature
                    + self.B * temperature**2
                    + self.C
                    * (temperature - 100.0)
                    * temperature**3
                )

                derivative = r0 * (
                    self.A
                    + 2.0 * self.B * temperature
                    + self.C
                    * (
                        4.0 * temperature**3
                        - 300.0 * temperature**2
                    )
                )

            error = (
                predicted - resistance_ohms
            )

            if abs(error) < 1e-7:
                break

            temperature -= error / derivative

        return temperature

    def read_temperature_c(self) -> float:
        """
        Read and return the PT100/PT1000 temperature in °C.
        """
        resistance = self.read_resistance_ohms()

        return round(
            self._temperature_from_resistance(
                resistance
            ),
            3,
        )
