"""Typing Helpers for Home Assistant."""
from __future__ import annotations

from collections.abc import Callable
from functools import lru_cache

from homeassistant.const import (
    CONCENTRATION_PARTS_PER_BILLION,
    CONCENTRATION_PARTS_PER_MILLION,
    PERCENTAGE,
    UNIT_NOT_RECOGNIZED_TEMPLATE,
    UnitOfDataRate,
    UnitOfElectricCurrent,
    UnitOfElectricPotential,
    UnitOfEnergy,
    UnitOfInformation,
    UnitOfLength,
    UnitOfMass,
    UnitOfPower,
    UnitOfPressure,
    UnitOfSpeed,
    UnitOfTemperature,
    UnitOfVolume,
    UnitOfVolumetricFlux,
)
from homeassistant.exceptions import HomeAssistantError

# Distance conversion constants
_MM_TO_M = 0.001  # 1 mm = 0.001 m
_CM_TO_M = 0.01  # 1 cm = 0.01 m
_KM_TO_M = 1000  # 1 km = 1000 m

_IN_TO_M = 0.0254  # 1 inch = 0.0254 m
_FOOT_TO_M = _IN_TO_M * 12  # 12 inches = 1 foot (0.3048 m)
_YARD_TO_M = _FOOT_TO_M * 3  # 3 feet = 1 yard (0.9144 m)
_MILE_TO_M = _YARD_TO_M * 1760  # 1760 yard = 1 mile (1609.344 m)

_NAUTICAL_MILE_TO_M = 1852  # 1 nautical mile = 1852 m

# Duration conversion constants
_HRS_TO_SECS = 60 * 60  # 1 hr = 3600 seconds
_DAYS_TO_SECS = 24 * _HRS_TO_SECS  # 1 day = 24 hours = 86400 seconds

# Mass conversion constants
_POUND_TO_G = 453.59237
_OUNCE_TO_G = _POUND_TO_G / 16  # 16 ounces to a pound
_STONE_TO_G = _POUND_TO_G * 14  # 14 pounds to a stone

# Pressure conversion constants
_STANDARD_GRAVITY = 9.80665
_MERCURY_DENSITY = 13.5951

# Volume conversion constants
_L_TO_CUBIC_METER = 0.001  # 1 L = 0.001 m³
_ML_TO_CUBIC_METER = 0.001 * _L_TO_CUBIC_METER  # 1 mL = 0.001 L
_GALLON_TO_CUBIC_METER = 231 * pow(_IN_TO_M, 3)  # US gallon is 231 cubic inches
_FLUID_OUNCE_TO_CUBIC_METER = _GALLON_TO_CUBIC_METER / 128  # 128 fl. oz. in a US gallon
_CUBIC_FOOT_TO_CUBIC_METER = pow(_FOOT_TO_M, 3)


class BaseUnitConverter:
    """Define the format of a conversion utility."""

    UNIT_CLASS: str
    NORMALIZED_UNIT: str | None
    VALID_UNITS: set[str | None]

    _UNIT_CONVERSION: dict[str | None, float]

    @classmethod
    def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float:
        """Convert one unit of measurement to another."""
        return cls.converter_factory(from_unit, to_unit)(value)

    @classmethod
    @lru_cache
    def converter_factory(
        cls, from_unit: str | None, to_unit: str | None
    ) -> Callable[[float], float]:
        """Return a function to convert one unit of measurement to another."""
        if from_unit == to_unit:
            return lambda value: value
        from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
        return lambda val: (val / from_ratio) * to_ratio

    @classmethod
    def _get_from_to_ratio(
        cls, from_unit: str | None, to_unit: str | None
    ) -> tuple[float, float]:
        """Get unit ratio between units of measurement."""
        unit_conversion = cls._UNIT_CONVERSION
        try:
            return unit_conversion[from_unit], unit_conversion[to_unit]
        except KeyError as err:
            raise HomeAssistantError(
                UNIT_NOT_RECOGNIZED_TEMPLATE.format(err.args[0], cls.UNIT_CLASS)
            ) from err

    @classmethod
    @lru_cache
    def converter_factory_allow_none(
        cls, from_unit: str | None, to_unit: str | None
    ) -> Callable[[float | None], float | None]:
        """Return a function to convert one unit of measurement to another which allows None."""
        if from_unit == to_unit:
            return lambda value: value
        from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
        return lambda val: None if val is None else (val / from_ratio) * to_ratio

    @classmethod
    @lru_cache
    def get_unit_ratio(cls, from_unit: str | None, to_unit: str | None) -> float:
        """Get unit ratio between units of measurement."""
        from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
        return from_ratio / to_ratio


class DataRateConverter(BaseUnitConverter):
    """Utility to convert data rate values."""

    UNIT_CLASS = "data_rate"
    NORMALIZED_UNIT = UnitOfDataRate.BITS_PER_SECOND
    # Units in terms of bits
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfDataRate.BITS_PER_SECOND: 1,
        UnitOfDataRate.KILOBITS_PER_SECOND: 1 / 1e3,
        UnitOfDataRate.MEGABITS_PER_SECOND: 1 / 1e6,
        UnitOfDataRate.GIGABITS_PER_SECOND: 1 / 1e9,
        UnitOfDataRate.BYTES_PER_SECOND: 1 / 8,
        UnitOfDataRate.KILOBYTES_PER_SECOND: 1 / 8e3,
        UnitOfDataRate.MEGABYTES_PER_SECOND: 1 / 8e6,
        UnitOfDataRate.GIGABYTES_PER_SECOND: 1 / 8e9,
        UnitOfDataRate.KIBIBYTES_PER_SECOND: 1 / 2**13,
        UnitOfDataRate.MEBIBYTES_PER_SECOND: 1 / 2**23,
        UnitOfDataRate.GIBIBYTES_PER_SECOND: 1 / 2**33,
    }
    VALID_UNITS = set(UnitOfDataRate)


class DistanceConverter(BaseUnitConverter):
    """Utility to convert distance values."""

    UNIT_CLASS = "distance"
    NORMALIZED_UNIT = UnitOfLength.METERS
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfLength.METERS: 1,
        UnitOfLength.MILLIMETERS: 1 / _MM_TO_M,
        UnitOfLength.CENTIMETERS: 1 / _CM_TO_M,
        UnitOfLength.KILOMETERS: 1 / _KM_TO_M,
        UnitOfLength.INCHES: 1 / _IN_TO_M,
        UnitOfLength.FEET: 1 / _FOOT_TO_M,
        UnitOfLength.YARDS: 1 / _YARD_TO_M,
        UnitOfLength.MILES: 1 / _MILE_TO_M,
    }
    VALID_UNITS = {
        UnitOfLength.KILOMETERS,
        UnitOfLength.MILES,
        UnitOfLength.FEET,
        UnitOfLength.METERS,
        UnitOfLength.CENTIMETERS,
        UnitOfLength.MILLIMETERS,
        UnitOfLength.INCHES,
        UnitOfLength.YARDS,
    }


class ElectricCurrentConverter(BaseUnitConverter):
    """Utility to convert electric current values."""

    UNIT_CLASS = "electric_current"
    NORMALIZED_UNIT = UnitOfElectricCurrent.AMPERE
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfElectricCurrent.AMPERE: 1,
        UnitOfElectricCurrent.MILLIAMPERE: 1e3,
    }
    VALID_UNITS = set(UnitOfElectricCurrent)


class ElectricPotentialConverter(BaseUnitConverter):
    """Utility to convert electric potential values."""

    UNIT_CLASS = "voltage"
    NORMALIZED_UNIT = UnitOfElectricPotential.VOLT
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfElectricPotential.VOLT: 1,
        UnitOfElectricPotential.MILLIVOLT: 1e3,
    }
    VALID_UNITS = {
        UnitOfElectricPotential.VOLT,
        UnitOfElectricPotential.MILLIVOLT,
    }


class EnergyConverter(BaseUnitConverter):
    """Utility to convert energy values."""

    UNIT_CLASS = "energy"
    NORMALIZED_UNIT = UnitOfEnergy.KILO_WATT_HOUR
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfEnergy.WATT_HOUR: 1 * 1000,
        UnitOfEnergy.KILO_WATT_HOUR: 1,
        UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1000,
        UnitOfEnergy.MEGA_JOULE: 3.6,
        UnitOfEnergy.GIGA_JOULE: 3.6 / 1000,
    }
    VALID_UNITS = {
        UnitOfEnergy.WATT_HOUR,
        UnitOfEnergy.KILO_WATT_HOUR,
        UnitOfEnergy.MEGA_WATT_HOUR,
        UnitOfEnergy.MEGA_JOULE,
        UnitOfEnergy.GIGA_JOULE,
    }


class InformationConverter(BaseUnitConverter):
    """Utility to convert information values."""

    UNIT_CLASS = "information"
    NORMALIZED_UNIT = UnitOfInformation.BITS
    # Units in terms of bits
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfInformation.BITS: 1,
        UnitOfInformation.KILOBITS: 1 / 1e3,
        UnitOfInformation.MEGABITS: 1 / 1e6,
        UnitOfInformation.GIGABITS: 1 / 1e9,
        UnitOfInformation.BYTES: 1 / 8,
        UnitOfInformation.KILOBYTES: 1 / 8e3,
        UnitOfInformation.MEGABYTES: 1 / 8e6,
        UnitOfInformation.GIGABYTES: 1 / 8e9,
        UnitOfInformation.TERABYTES: 1 / 8e12,
        UnitOfInformation.PETABYTES: 1 / 8e15,
        UnitOfInformation.EXABYTES: 1 / 8e18,
        UnitOfInformation.ZETTABYTES: 1 / 8e21,
        UnitOfInformation.YOTTABYTES: 1 / 8e24,
        UnitOfInformation.KIBIBYTES: 1 / 2**13,
        UnitOfInformation.MEBIBYTES: 1 / 2**23,
        UnitOfInformation.GIBIBYTES: 1 / 2**33,
        UnitOfInformation.TEBIBYTES: 1 / 2**43,
        UnitOfInformation.PEBIBYTES: 1 / 2**53,
        UnitOfInformation.EXBIBYTES: 1 / 2**63,
        UnitOfInformation.ZEBIBYTES: 1 / 2**73,
        UnitOfInformation.YOBIBYTES: 1 / 2**83,
    }
    VALID_UNITS = set(UnitOfInformation)


class MassConverter(BaseUnitConverter):
    """Utility to convert mass values."""

    UNIT_CLASS = "mass"
    NORMALIZED_UNIT = UnitOfMass.GRAMS
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfMass.MICROGRAMS: 1 * 1000 * 1000,
        UnitOfMass.MILLIGRAMS: 1 * 1000,
        UnitOfMass.GRAMS: 1,
        UnitOfMass.KILOGRAMS: 1 / 1000,
        UnitOfMass.OUNCES: 1 / _OUNCE_TO_G,
        UnitOfMass.POUNDS: 1 / _POUND_TO_G,
        UnitOfMass.STONES: 1 / _STONE_TO_G,
    }
    VALID_UNITS = {
        UnitOfMass.GRAMS,
        UnitOfMass.KILOGRAMS,
        UnitOfMass.MILLIGRAMS,
        UnitOfMass.MICROGRAMS,
        UnitOfMass.OUNCES,
        UnitOfMass.POUNDS,
        UnitOfMass.STONES,
    }


class PowerConverter(BaseUnitConverter):
    """Utility to convert power values."""

    UNIT_CLASS = "power"
    NORMALIZED_UNIT = UnitOfPower.WATT
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfPower.WATT: 1,
        UnitOfPower.KILO_WATT: 1 / 1000,
    }
    VALID_UNITS = {
        UnitOfPower.WATT,
        UnitOfPower.KILO_WATT,
    }


class PressureConverter(BaseUnitConverter):
    """Utility to convert pressure values."""

    UNIT_CLASS = "pressure"
    NORMALIZED_UNIT = UnitOfPressure.PA
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfPressure.PA: 1,
        UnitOfPressure.HPA: 1 / 100,
        UnitOfPressure.KPA: 1 / 1000,
        UnitOfPressure.BAR: 1 / 100000,
        UnitOfPressure.CBAR: 1 / 1000,
        UnitOfPressure.MBAR: 1 / 100,
        UnitOfPressure.INHG: 1
        / (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
        UnitOfPressure.PSI: 1 / 6894.757,
        UnitOfPressure.MMHG: 1
        / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
    }
    VALID_UNITS = {
        UnitOfPressure.PA,
        UnitOfPressure.HPA,
        UnitOfPressure.KPA,
        UnitOfPressure.BAR,
        UnitOfPressure.CBAR,
        UnitOfPressure.MBAR,
        UnitOfPressure.INHG,
        UnitOfPressure.PSI,
        UnitOfPressure.MMHG,
    }


class SpeedConverter(BaseUnitConverter):
    """Utility to convert speed values."""

    UNIT_CLASS = "speed"
    NORMALIZED_UNIT = UnitOfSpeed.METERS_PER_SECOND
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfVolumetricFlux.INCHES_PER_DAY: _DAYS_TO_SECS / _IN_TO_M,
        UnitOfVolumetricFlux.INCHES_PER_HOUR: _HRS_TO_SECS / _IN_TO_M,
        UnitOfVolumetricFlux.MILLIMETERS_PER_DAY: _DAYS_TO_SECS / _MM_TO_M,
        UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: _HRS_TO_SECS / _MM_TO_M,
        UnitOfSpeed.FEET_PER_SECOND: 1 / _FOOT_TO_M,
        UnitOfSpeed.KILOMETERS_PER_HOUR: _HRS_TO_SECS / _KM_TO_M,
        UnitOfSpeed.KNOTS: _HRS_TO_SECS / _NAUTICAL_MILE_TO_M,
        UnitOfSpeed.METERS_PER_SECOND: 1,
        UnitOfSpeed.MILES_PER_HOUR: _HRS_TO_SECS / _MILE_TO_M,
    }
    VALID_UNITS = {
        UnitOfVolumetricFlux.INCHES_PER_DAY,
        UnitOfVolumetricFlux.INCHES_PER_HOUR,
        UnitOfVolumetricFlux.MILLIMETERS_PER_DAY,
        UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
        UnitOfSpeed.FEET_PER_SECOND,
        UnitOfSpeed.KILOMETERS_PER_HOUR,
        UnitOfSpeed.KNOTS,
        UnitOfSpeed.METERS_PER_SECOND,
        UnitOfSpeed.MILES_PER_HOUR,
    }


class TemperatureConverter(BaseUnitConverter):
    """Utility to convert temperature values."""

    UNIT_CLASS = "temperature"
    NORMALIZED_UNIT = UnitOfTemperature.CELSIUS
    VALID_UNITS = {
        UnitOfTemperature.CELSIUS,
        UnitOfTemperature.FAHRENHEIT,
        UnitOfTemperature.KELVIN,
    }
    _UNIT_CONVERSION = {
        UnitOfTemperature.CELSIUS: 1.0,
        UnitOfTemperature.FAHRENHEIT: 1.8,
        UnitOfTemperature.KELVIN: 1.0,
    }

    @classmethod
    @lru_cache(maxsize=8)
    def converter_factory(
        cls, from_unit: str | None, to_unit: str | None
    ) -> Callable[[float], float]:
        """Return a function to convert a temperature from one unit to another."""
        if from_unit == to_unit:
            # Return a function that does nothing. This is not
            # in _converter_factory because we do not want to wrap
            # it with the None check in converter_factory_allow_none.
            return lambda value: value

        return cls._converter_factory(from_unit, to_unit)

    @classmethod
    @lru_cache(maxsize=8)
    def converter_factory_allow_none(
        cls, from_unit: str | None, to_unit: str | None
    ) -> Callable[[float | None], float | None]:
        """Return a function to convert a temperature from one unit to another which allows None."""
        if from_unit == to_unit:
            # Return a function that does nothing. This is not
            # in _converter_factory because we do not want to wrap
            # it with the None check in this case.
            return lambda value: value
        convert = cls._converter_factory(from_unit, to_unit)
        return lambda value: None if value is None else convert(value)

    @classmethod
    def _converter_factory(
        cls, from_unit: str | None, to_unit: str | None
    ) -> Callable[[float], float]:
        """Convert a temperature from one unit to another.

        eg. 10°C will return 50°F

        For converting an interval between two temperatures, please use
        `convert_interval` instead.
        """
        # We cannot use the implementation from BaseUnitConverter here because the
        # temperature units do not use the same floor: 0°C, 0°F and 0K do not align
        if from_unit == UnitOfTemperature.CELSIUS:
            if to_unit == UnitOfTemperature.FAHRENHEIT:
                return cls._celsius_to_fahrenheit
            if to_unit == UnitOfTemperature.KELVIN:
                return cls._celsius_to_kelvin
            raise HomeAssistantError(
                UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS)
            )

        if from_unit == UnitOfTemperature.FAHRENHEIT:
            if to_unit == UnitOfTemperature.CELSIUS:
                return cls._fahrenheit_to_celsius
            if to_unit == UnitOfTemperature.KELVIN:
                return cls._fahrenheit_to_kelvin
            raise HomeAssistantError(
                UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS)
            )

        if from_unit == UnitOfTemperature.KELVIN:
            if to_unit == UnitOfTemperature.CELSIUS:
                return cls._kelvin_to_celsius
            if to_unit == UnitOfTemperature.FAHRENHEIT:
                return cls._kelvin_to_fahrenheit
            raise HomeAssistantError(
                UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS)
            )
        raise HomeAssistantError(
            UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, cls.UNIT_CLASS)
        )

    @classmethod
    def convert_interval(cls, interval: float, from_unit: str, to_unit: str) -> float:
        """Convert a temperature interval from one unit to another.

        eg. a 10°C interval (10°C to 20°C) will return a 18°F (50°F to 68°F) interval

        For converting a temperature value, please use `convert` as this method
        skips floor adjustment.
        """
        # We use BaseUnitConverter implementation here because we are only interested
        # in the ratio between the units.
        return super().converter_factory(from_unit, to_unit)(interval)

    @classmethod
    def _kelvin_to_fahrenheit(cls, kelvin: float) -> float:
        """Convert a temperature in Kelvin to Fahrenheit."""
        return (kelvin - 273.15) * 1.8 + 32.0

    @classmethod
    def _fahrenheit_to_kelvin(cls, fahrenheit: float) -> float:
        """Convert a temperature in Fahrenheit to Kelvin."""
        return 273.15 + ((fahrenheit - 32.0) / 1.8)

    @classmethod
    def _fahrenheit_to_celsius(cls, fahrenheit: float) -> float:
        """Convert a temperature in Fahrenheit to Celsius."""
        return (fahrenheit - 32.0) / 1.8

    @classmethod
    def _kelvin_to_celsius(cls, kelvin: float) -> float:
        """Convert a temperature in Kelvin to Celsius."""
        return kelvin - 273.15

    @classmethod
    def _celsius_to_fahrenheit(cls, celsius: float) -> float:
        """Convert a temperature in Celsius to Fahrenheit."""
        return celsius * 1.8 + 32.0

    @classmethod
    def _celsius_to_kelvin(cls, celsius: float) -> float:
        """Convert a temperature in Celsius to Kelvin."""
        return celsius + 273.15


class UnitlessRatioConverter(BaseUnitConverter):
    """Utility to convert unitless ratios."""

    UNIT_CLASS = "unitless"
    NORMALIZED_UNIT = None
    _UNIT_CONVERSION: dict[str | None, float] = {
        None: 1,
        CONCENTRATION_PARTS_PER_BILLION: 1000000000,
        CONCENTRATION_PARTS_PER_MILLION: 1000000,
        PERCENTAGE: 100,
    }
    VALID_UNITS = {
        None,
        PERCENTAGE,
    }


class VolumeConverter(BaseUnitConverter):
    """Utility to convert volume values."""

    UNIT_CLASS = "volume"
    NORMALIZED_UNIT = UnitOfVolume.CUBIC_METERS
    # Units in terms of m³
    _UNIT_CONVERSION: dict[str | None, float] = {
        UnitOfVolume.LITERS: 1 / _L_TO_CUBIC_METER,
        UnitOfVolume.MILLILITERS: 1 / _ML_TO_CUBIC_METER,
        UnitOfVolume.GALLONS: 1 / _GALLON_TO_CUBIC_METER,
        UnitOfVolume.FLUID_OUNCES: 1 / _FLUID_OUNCE_TO_CUBIC_METER,
        UnitOfVolume.CUBIC_METERS: 1,
        UnitOfVolume.CUBIC_FEET: 1 / _CUBIC_FOOT_TO_CUBIC_METER,
        UnitOfVolume.CENTUM_CUBIC_FEET: 1 / (100 * _CUBIC_FOOT_TO_CUBIC_METER),
    }
    VALID_UNITS = {
        UnitOfVolume.LITERS,
        UnitOfVolume.MILLILITERS,
        UnitOfVolume.GALLONS,
        UnitOfVolume.FLUID_OUNCES,
        UnitOfVolume.CUBIC_METERS,
        UnitOfVolume.CUBIC_FEET,
        UnitOfVolume.CENTUM_CUBIC_FEET,
    }
