#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
"""RAMSES RF - Protocol/Transport layer - Helper functions."""

from __future__ import annotations

import ctypes
import sys
import time
from collections.abc import Iterable, Mapping
from datetime import date, datetime as dt
from typing import TYPE_CHECKING, Final, Literal, TypeAlias

from .const import (
    SZ_AIR_QUALITY,
    SZ_AIR_QUALITY_BASIS,
    SZ_BYPASS_POSITION,
    SZ_CO2_LEVEL,
    SZ_DEWPOINT_TEMP,
    SZ_EXHAUST_FAN_SPEED,
    SZ_EXHAUST_FLOW,
    SZ_EXHAUST_TEMP,
    SZ_FAN_INFO,
    SZ_HEAT_DEMAND,
    SZ_INDOOR_HUMIDITY,
    SZ_INDOOR_TEMP,
    SZ_OUTDOOR_HUMIDITY,
    SZ_OUTDOOR_TEMP,
    SZ_POST_HEAT,
    SZ_PRE_HEAT,
    SZ_REMAINING_MINS,
    SZ_SPEED_CAPABILITIES,
    SZ_SUPPLY_FAN_SPEED,
    SZ_SUPPLY_FLOW,
    SZ_SUPPLY_TEMP,
    SZ_TEMPERATURE,
)
from .ramses import _31DA_FAN_INFO

if TYPE_CHECKING:
    from .typed_dicts import PayDictT

# Sensor faults
SZ_UNRELIABLE: Final = "unreliable"
SZ_TOO_HIGH: Final = "out_of_range_high"
SZ_TOO_LOW: Final = "out_of_range_low"
# Actuator, Valve/damper faults
SZ_STUCK_VALVE: Final = "stuck_valve"  # Damper/Valve jammed
SZ_STUCK_ACTUATOR: Final = "stuck_actuator"  # Actuator jammed
# Common (to both) faults
SZ_OPEN_CIRCUIT: Final = "open_circuit"
SZ_SHORT_CIRCUIT: Final = "short_circuit"
SZ_UNAVAILABLE: Final = "unavailable"
SZ_OTHER_FAULT: Final = "other_fault"  # Non-specific fault

DEVICE_FAULT_CODES = {
    0x0: SZ_OPEN_CIRCUIT,  # NOTE: open, short
    0x1: SZ_SHORT_CIRCUIT,
    0x2: SZ_UNAVAILABLE,
    0xD: SZ_STUCK_VALVE,
    0xE: SZ_STUCK_ACTUATOR,
    0xF: SZ_OTHER_FAULT,
}
SENSOR_FAULT_CODES = {
    0x0: SZ_SHORT_CIRCUIT,  # NOTE: short, open
    0x1: SZ_OPEN_CIRCUIT,
    0x2: SZ_UNAVAILABLE,
    0x3: SZ_TOO_HIGH,
    0x4: SZ_TOO_LOW,
    0x5: SZ_UNRELIABLE,
    # 0xF: SZ_OTHER_FAULT,  # No evidence is explicitly part of the specification
}


# TODO: consider returning from helpers as TypeGuard[HexByte]
# fmt: off
HexByteAlt = Literal[
    '00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0A', '0B', '0C', '0D', '0E', '0F',
    '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '1A', '1B', '1C', '1D', '1E', '1F',
    '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '2A', '2B', '2C', '2D', '2E', '2F',
    '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '3A', '3B', '3C', '3D', '3E', '3F',
    '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '4A', '4B', '4C', '4D', '4E', '4F',
    '50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '5A', '5B', '5C', '5D', '5E', '5F',
    '60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '6A', '6B', '6C', '6D', '6E', '6F',
    '70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '7A', '7B', '7C', '7D', '7E', '7F',
    '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '8A', '8B', '8C', '8D', '8E', '8F',
    '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '9A', '9B', '9C', '9D', '9E', '9F',
    'A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'AA', 'AB', 'AC', 'AD', 'AE', 'AF',
    'B0', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B9', 'BA', 'BB', 'BC', 'BD', 'BE', 'BF',
    'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9', 'CA', 'CB', 'CC', 'CD', 'CE', 'CF',
    'D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'D9', 'DA', 'DB', 'DC', 'DD', 'DE', 'DF',
    'E0', 'E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9', 'EA', 'EB', 'EC', 'ED', 'EE', 'EF',
    'F0', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'FA', 'FB', 'FC', 'FD', 'FE', 'FF'
]
# fmt: on

HexByte: TypeAlias = str
HexStr2: TypeAlias = str  # two characters, one byte
HexStr4: TypeAlias = str
HexStr8: TypeAlias = str
HexStr12: TypeAlias = str
HexStr14: TypeAlias = str


ReturnValueDictT: TypeAlias = Mapping[str, float | str | None]


class _FILE_TIME(ctypes.Structure):
    """Data structure for GetSystemTimePreciseAsFileTime()."""

    _fields_ = [("dwLowDateTime", ctypes.c_uint), ("dwHighDateTime", ctypes.c_uint)]


file_time = _FILE_TIME()


def timestamp() -> float:
    """Return the number of seconds since the Unix epoch.

    Return an accurate value, even for Windows-based systems.
    """

    # see: https://www.python.org/dev/peps/pep-0564/
    if sys.platform != "win32":  # since 1970-01-01T00:00:00Z, time.gmtime(0)
        return time.time_ns() / 1e9

    # otherwise, is since 1601-01-01T00:00:00Z
    ctypes.windll.kernel32.GetSystemTimePreciseAsFileTime(ctypes.byref(file_time))  # type: ignore[unreachable]
    _time = (file_time.dwLowDateTime + (file_time.dwHighDateTime << 32)) / 1e7
    return _time - 134774 * 24 * 60 * 60


def dt_now() -> dt:
    """Return the current datetime as a local/naive datetime object.

    This is slower, but potentially more accurate, than dt.now(), and is used mainly for
    packet timestamps.
    """
    if sys.platform == "win32":
        return dt.fromtimestamp(timestamp())
    return dt.now()


def dt_str() -> str:
    """Return the current datetime as a isoformat string."""
    return dt_now().isoformat(timespec="microseconds")


####################################################################################################


def hex_to_bool(value: HexStr2) -> bool | None:  # either False, True or None
    """Convert a 2-char hex string into a boolean."""
    if not isinstance(value, str) or len(value) != 2:
        raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
    if value == "FF":
        return None
    return {"00": False, "C8": True}[value]


def hex_from_bool(value: bool | None) -> HexStr2:  # either 00, C8 or FF
    """Convert a boolean into a 2-char hex string."""
    if value is None:
        return "FF"
    if not isinstance(value, bool):
        raise ValueError(f"Invalid value: {value}, is not bool")
    return {False: "00", True: "C8"}[value]


def hex_to_date(value: HexStr8) -> str | None:  # YY-MM-DD
    """Convert am 8-char hex string into a date, format YY-MM-DD."""
    if not isinstance(value, str) or len(value) != 8:
        raise ValueError(f"Invalid value: {value}, is not an 8-char hex string")
    if value == "FFFFFFFF":
        return None
    return dt(
        year=int(value[4:8], 16),
        month=int(value[2:4], 16),
        day=int(value[:2], 16) & 0b11111,  # 1st 3 bits: DayOfWeek
    ).strftime("%Y-%m-%d")


# FIXME: factor=1 should return an int
def hex_to_double(value: HexStr4, factor: int = 1) -> float | None:
    """Convert a 4-char hex string into a double."""
    if not isinstance(value, str) or len(value) != 4:
        raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
    if value == "7FFF":
        return None
    return int(value, 16) / factor


def hex_from_double(value: float | None, factor: int = 1) -> HexStr4:
    """Convert a double into 4-char hex string."""
    if value is None:
        return "7FFF"
    if not isinstance(value, float | int):
        raise ValueError(f"Invalid value: {value}, is not a double (a float/int)")
    return f"{int(value * factor):04X}"


def hex_to_dtm(value: HexStr12 | HexStr14) -> str | None:  # from parsers
    """Convert a 12/14-char hex string to an isoformat datetime (naive, local)."""
    #        00141B0A07E3  (...HH:MM:00)    for system_mode, zone_mode (schedules?)
    #      0400041C0A07E3  (...HH:MM:SS)    for sync_datetime

    if not isinstance(value, str) or len(value) not in (12, 14):
        raise ValueError(f"Invalid value: {value}, is not a 12/14-char hex string")
    if value[-12:] == "FF" * 6:
        return None
    if len(value) == 12:
        value = f"00{value}"
    return dt(
        year=int(value[10:14], 16),
        month=int(value[8:10], 16),
        day=int(value[6:8], 16),
        hour=int(value[4:6], 16) & 0b11111,  # 1st 3 bits: DayOfWeek
        minute=int(value[2:4], 16),
        second=int(value[:2], 16) & 0b1111111,  # 1st bit: used for DST
    ).isoformat(timespec="seconds")


def hex_from_dtm(
    dtm: date | dt | str | None, is_dst: bool = False, incl_seconds: bool = False
) -> HexStr12 | HexStr14:
    """Convert a datetime (isoformat str, or naive dtm) to a 12/14-char hex str."""

    def _dtm_to_hex(tm_year, tm_mon, tm_mday, tm_hour, tm_min, tm_sec, *args):
        return (
            f"{tm_sec:02X}{tm_min:02X}{tm_hour:02X}"
            f"{tm_mday:02X}{tm_mon:02X}{tm_year:04X}"
        )

    if dtm is None:
        return "FF" * (7 if incl_seconds else 6)
    if isinstance(dtm, str):
        dtm = dt.fromisoformat(dtm)
    dtm_str = _dtm_to_hex(*dtm.timetuple())  # TODO: add DST for tm_isdst
    if is_dst:
        dtm_str = f"{int(dtm_str[:2], 16) | 0x80:02X}" + dtm_str[2:]
    return dtm_str if incl_seconds else dtm_str[2:]  # type: ignore[no-any-return]


def hex_to_dts(value: HexStr12) -> str | None:
    """YY-MM-DD HH:MM:SS."""
    if not isinstance(value, str) or len(value) != 12:
        raise ValueError(f"Invalid value: {value}, is not a 12-char hex string")
    if value == "00000000007F":
        return None
    _seqx = int(value, 16)
    return dt(
        year=(_seqx & 0b1111111 << 24) >> 24,
        month=(_seqx & 0b1111 << 36) >> 36,
        day=(_seqx & 0b11111 << 31) >> 31,
        hour=(_seqx & 0b11111 << 19) >> 19,
        minute=(_seqx & 0b111111 << 13) >> 13,
        second=(_seqx & 0b111111 << 7) >> 7,
    ).strftime("%y-%m-%dT%H:%M:%S")


def hex_from_dts(dtm: dt | str | None) -> HexStr12:  # TODO: WIP
    """Convert a datetime (isoformat str, or dtm) to a packed 12-char hex str."""
    """YY-MM-DD HH:MM:SS."""
    if dtm is None:
        return "00000000007F"
    if isinstance(dtm, str):
        dtm = dt.fromisoformat(dtm)  # TODO: YY-MM-DD, not YYYY-MM-DD
    (tm_year, tm_mon, tm_mday, tm_hour, tm_min, tm_sec, *_) = dtm.timetuple()
    result = sum(
        (
            tm_year % 100 << 24,
            tm_mon << 36,
            tm_mday << 31,
            tm_hour << 19,
            tm_min << 13,
            tm_sec << 7,
        )
    )
    return f"{result:012X}"


def hex_to_flag8(byte: HexByte, lsb: bool = False) -> list[int]:  # TODO: use tuple
    """Split a hex str (a byte) into a list of 8 bits, MSB as first bit by default.

    If lsb==True, then the LSB is first.
    The `lsb` boolean is used so that flag[0] is `zone_idx["00"]`, etc.
    """
    if not isinstance(byte, str) or len(byte) != 2:
        raise ValueError(f"Invalid value: '{byte}', is not a 2-char hex string")
    if lsb:  # make LSB is first bit
        return list((int(byte, 16) & (1 << x)) >> x for x in range(8))
    return list((int(byte, 16) & (1 << x)) >> x for x in reversed(range(8)))


def hex_from_flag8(flags: Iterable[int], lsb: bool = False) -> HexByte:
    """Convert list of 8 bits, MSB bit 1 by default, to an two-char ASCII hex string.

    The `lsb` boolean is used so that flag[0] is `zone_idx["00"]`, etc.
    """
    if not isinstance(flags, list) or len(flags) != 8:
        raise ValueError(f"Invalid value: '{flags}', is not a list of 8 bits")
    if lsb:  # LSB is first bit
        return f"{sum(x<<idx for idx, x in enumerate(flags)):02X}"
    return f"{sum(x<<idx for idx, x in enumerate(reversed(flags))):02X}"


# TODO: add a wrapper for EF, & 0xF0
def hex_to_percent(
    value: HexStr2, high_res: bool = True
) -> float | None:  # c.f. valve_demand
    """Convert a 2-char hex string into a percentage.

    The range is 0-100%, with resolution of 0.5% (high_res, 00-C8) or 1% (00-64).
    """
    if not isinstance(value, str) or len(value) != 2:
        raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
    if value == "EF":  # TODO: when EF, when 7F?
        return None  # TODO: raise NotImplementedError
    if (raw_result := int(value, 16)) & 0xF0 == 0xF0:
        return None  # TODO: raise errors
    result = float(raw_result) / (200 if high_res else 100)
    if result > 1.0:  # move to outer wrapper
        raise ValueError(f"Invalid result: {result} (0x{value}) is > 1")
    return result


def hex_to_str(value: str) -> str:  # printable ASCII characters
    """Return a string of printable ASCII characters."""
    # result = bytearray.fromhex(value).split(b"\x7F")[0]  # TODO: needs checking
    if not isinstance(value, str):
        raise ValueError(f"Invalid value: {value}, is not a string")
    result = bytearray([x for x in bytearray.fromhex(value) if 31 < x < 127])
    return result.decode("ascii").strip() if result else ""


def hex_from_str(value: str) -> str:
    """Convert a string to a variable-length ASCII hex string."""
    if not isinstance(value, str):
        raise ValueError(f"Invalid value: {value}, is not a string")
    return "".join(f"{ord(x):02X}" for x in value)  # or: value.encode().hex()


def hex_to_temp(value: HexStr4) -> bool | float | None:  # TODO: remove bool
    """Convert a 2's complement 4-byte hex string to an float."""
    if not isinstance(value, str) or len(value) != 4:
        raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
    if value == "31FF":  # means: N/A (== 127.99, 2s complement), signed?
        return None
    if value == "7EFF":  # possibly only for setpoints? unsigned?
        return False
    if value == "7FFF":  # also: FFFF?, means: N/A (== 327.67)
        return None
    temp: float = int(value, 16)
    temp = (temp if temp < 2**15 else temp - 2**16) / 100
    if temp < -273.15:
        raise ValueError(f"Invalid value: {temp} (0x{value}) is < -273.15")
    return temp


def hex_from_temp(value: bool | float | None) -> HexStr4:
    """Convert a float to a 2's complement 4-byte hex string."""
    if value is None:
        return "7FFF"  # or: "31FF"?
    if value is False:
        return "7EFF"
    if not isinstance(value, float | int):
        raise TypeError(f"Invalid temp: {value} is not a float")
    # if not -(2**7) <= value < 2**7:  # TODO: tighten range
    #     raise ValueError(f"Invalid temp: {value} is out of range")
    temp = int(value * 100)
    return f"{temp if temp >= 0 else temp + 2 ** 16:04X}"


########################################################################################


def _faulted_common(param_name: str, value: str) -> dict[str, str]:
    return {f"{param_name}_fault": f"invalid_{value}"}


def _faulted_sensor(param_name: str, value: str) -> dict[str, str]:
    # assert value[:1] in ("8", "F"), value
    code = int(value[:2], 16) & 0xF
    fault = SENSOR_FAULT_CODES.get(code, f"invalid_{value}")
    return {f"{param_name}_fault": fault}


def _faulted_device(param_name: str, value: str) -> dict[str, str]:
    assert value[:1] in ("8", "F"), value
    code = int(value[:2], 16) & 0xF
    fault: str = DEVICE_FAULT_CODES.get(code, f"invalid_{value}")
    return {f"{param_name}_fault": fault}


# TODO: refactor as per 31DA parsers
def parse_valve_demand(
    value: HexStr2,
) -> dict[str, float] | dict[str, str] | dict[str, None]:
    """Convert a 2-char hex string into a percentage.

    The range is 0-100%, with resolution of 0.5% (high_res) or 1%.
    """  # for a damper (restricts flow), or a valve (permits flow)

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 2:
        raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")

    if value == "EF":
        return {SZ_HEAT_DEMAND: None}  # Not Implemented

    if int(value, 16) & 0xF0 == 0xF0:
        return _faulted_device(SZ_HEAT_DEMAND, value)

    result = int(value, 16) / 200  # c.f. hex_to_percentage
    if result == 1.01:  # HACK - does it mean maximum?
        result = 1.0
    elif result > 1.0:
        raise ValueError(f"Invalid result: {result} (0x{value}) is > 1")

    return {SZ_HEAT_DEMAND: result}


# 31DA[2:6] and 12C8[2:6]
def parse_air_quality(value: HexStr4) -> PayDictT.AIR_QUALITY:
    """Return the air quality (%): poor (0.0) to excellent (1.0).

    The basis of the air quality level should be one of: VOC, CO2 or relative humidity.
    If air_quality is EF, air_quality_basis should be 00.

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """  # VOC: Volatile organic compounds

    # TODO: remove this as API used only internally...
    if not isinstance(value, str) or len(value) != 4:
        raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")

    assert value[:2] != "EF" or value[2:] == "00", value  # TODO: raise exception
    if value == "EF00":  # Not implemented
        return {SZ_AIR_QUALITY: None}

    if int(value[:2], 16) & 0xF0 == 0xF0:
        return _faulted_sensor(SZ_AIR_QUALITY, value)  # type: ignore[return-value]

    level = int(value[:2], 16) / 200  # was: hex_to_percent(value[:2])
    assert level <= 1.0, value[:2]  # TODO: raise exception

    assert value[2:] in ("10", "20", "40"), value[2:]  # TODO: remove assert
    basis = {
        "10": "voc",  # volatile compounds
        "20": "co2",  # carbdon dioxide
        "40": "rel_humidity",  # relative humidity
    }.get(value[2:], f"unknown_{value[2:]}")  # TODO: remove get/unknown

    return {SZ_AIR_QUALITY: level, SZ_AIR_QUALITY_BASIS: basis}


# 31DA[6:10] and 1298[2:6]
def parse_co2_level(value: HexStr4) -> PayDictT.CO2_LEVEL:
    """Return the co2 level (ppm).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 4:
        raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")

    if value == "7FFF":  # Not implemented
        return {SZ_CO2_LEVEL: None}

    level = int(value, 16)  # was: hex_to_double(value)  # is it 2's complement?

    if int(value[:2], 16) & 0x80 or level >= 0x8000:
        return _faulted_sensor(SZ_CO2_LEVEL, value)  # type: ignore[return-value]

    # assert int(value[:2], 16) <= 0x8000, value
    return {SZ_CO2_LEVEL: level}


# 31DA[10:12] and 12A0[2:12]
def parse_indoor_humidity(value: str) -> PayDictT.INDOOR_HUMIDITY:
    """Return the relative indoor humidity (%).

    The result may include current temperature ('C), and dewpoint temperature ('C).
    """
    return _parse_hvac_humidity(SZ_INDOOR_HUMIDITY, value[:2], value[2:6], value[6:10])  # type: ignore[return-value]


# 31DA[12:14] and 1280[2:12]
def parse_outdoor_humidity(value: str) -> PayDictT.OUTDOOR_HUMIDITY:
    """Return the relative outdoor humidity (%).

    The result may include current temperature ('C), and dewpoint temperature ('C).
    """
    return _parse_hvac_humidity(SZ_OUTDOOR_HUMIDITY, value[:2], value[2:6], value[6:10])  # type: ignore[return-value]


def _parse_hvac_humidity(
    param_name: str, value: HexStr2, temp: HexStr4, dewpoint: HexStr4
) -> ReturnValueDictT:
    """Return the relative humidity, etc. (called by sensor parsers).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 2:
        raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
    if not isinstance(temp, str) or len(temp) not in (0, 4):
        raise ValueError(f"Invalid temp: {temp}, is not a 4-char hex string")
    if not isinstance(dewpoint, str) or len(dewpoint) not in (0, 4):
        raise ValueError(f"Invalid dewpoint: {dewpoint}, is not a 4-char hex string")

    if value == "EF":  # Not implemented
        return {param_name: None}

    if int(value, 16) & 0xF0 == 0xF0:
        return _faulted_sensor(param_name, value)

    percentage = int(value, 16) / 100  # TODO: confirm not 200
    assert percentage <= 1.0, value  # TODO: raise exception if > 1.0?

    result: dict[str, float | str | None] = {
        param_name: percentage
    }  # was: percent_from_hex(value, high_res=False)
    if temp:
        result |= {SZ_TEMPERATURE: hex_to_temp(temp)}
    if dewpoint:
        result |= {SZ_DEWPOINT_TEMP: hex_to_temp(dewpoint)}
    return result


# 31DA[14:18]
def parse_exhaust_temp(value: HexStr4) -> PayDictT.EXHAUST_TEMP:
    """Return the exhaust temperature ('C)."""
    return _parse_hvac_temp(SZ_EXHAUST_TEMP, value)  # type: ignore[return-value]


# 31DA[18:22]
def parse_supply_temp(value: HexStr4) -> PayDictT.SUPPLY_TEMP:
    """Return the supply temperature ('C)."""
    return _parse_hvac_temp(SZ_SUPPLY_TEMP, value)  # type: ignore[return-value]


# 31DA[22:26]
def parse_indoor_temp(value: HexStr4) -> PayDictT.INDOOR_TEMP:
    """Return the indoor temperature ('C)."""
    return _parse_hvac_temp(SZ_INDOOR_TEMP, value)  # type: ignore[return-value]


# 31DA[26:30] & 1290[2:6]?
def parse_outdoor_temp(value: HexStr4) -> PayDictT.OUTDOOR_TEMP:
    """Return the outdoor temperature ('C)."""
    return _parse_hvac_temp(SZ_OUTDOOR_TEMP, value)  # type: ignore[return-value]


def _parse_hvac_temp(param_name: str, value: HexStr4) -> Mapping[str, float | None]:
    """Return the temperature ('C) (called by sensor parsers).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 4:
        raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")

    if value == "7FFF":  # Not implemented
        return {param_name: None}
    if value == "31FF":  # Other
        return {param_name: None}

    if int(value[:2], 16) & 0xF0 == 0x80:  # or temperature < -273.15:
        return _faulted_sensor(param_name, value)  # type: ignore[return-value]

    temp: float = int(value, 16)
    temp = (temp if temp < 2**15 else temp - 2**16) / 100
    if temp <= -273:  # TODO: < 273.15?
        return _faulted_sensor(param_name, value)  # type: ignore[return-value]

    return {param_name: temp}


# 31DA[30:34]
def parse_bypass_position(value: HexStr2) -> PayDictT.BYPASS_POSITION:
    """Return the bypass position (%), usually fully open or closed (0%, no bypass).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 2:
        raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")

    if value == "EF":  # Not implemented
        return {SZ_BYPASS_POSITION: None}

    if int(value[:2], 16) & 0xF0 == 0xF0:
        return _faulted_device(SZ_BYPASS_POSITION, value)  # type: ignore[return-value]

    bypass_pos = int(value, 16) / 200  # was: hex_to_percent(value)
    assert bypass_pos <= 1.0, value

    return {SZ_BYPASS_POSITION: bypass_pos}


# 31DA[34:36]
def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
    """Return the speed capabilities (a bitmask).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 4:
        raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")

    if value == "7FFF":  # TODO: Not implemented???
        return {SZ_SPEED_CAPABILITIES: None}

    ABILITIES = {
        15: "off",
        14: "low_med_high",  # 3,2,1 = high,med,low?
        13: "timer",
        12: "boost",
        11: "auto",
        10: "speed_4",
        9: "speed_5",
        8: "speed_6",
        7: "speed_7",
        6: "speed_8",
        5: "speed_9",
        4: "speed_10",
        3: "auto_night",
        2: "reserved",
        1: "post_heater",
        0: "pre_heater",
    }

    # assert value in ("0002", "4000", "4808", "F000", "F001", "F800", "F808"), value

    return {
        SZ_SPEED_CAPABILITIES: [
            v for k, v in ABILITIES.items() if int(value, 16) & 2**k
        ]
    }


# 31DA[36:38]  # TODO: WIP (3 more bits), also 22F3?
def parse_fan_info(value: HexStr2) -> PayDictT.FAN_INFO:
    """Return the fan info (current speed, and...).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 2:
        raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")

    # if value == "EF":  # TODO: Not implemented???
    #     return {SZ_FAN_INFO: None}

    assert int(value, 16) & 0x1F <= 0x19, f"invalid fan_info: {int(value, 16) & 0x1F}"
    assert int(value, 16) & 0xE0 in (
        0x00,
        0x20,
        0x40,
        0x60,
        0x80,
    ), f"invalid fan_info: {int(value, 16) & 0xE0}"

    flags = list((int(value, 16) & (1 << x)) >> x for x in range(7, 4, -1))

    return {
        SZ_FAN_INFO: _31DA_FAN_INFO[int(value, 16) & 0x1F],
        "_unknown_fan_info_flags": flags,
    }


# 31DA[38:40]
def parse_exhaust_fan_speed(value: HexStr2) -> PayDictT.EXHAUST_FAN_SPEED:
    """Return the exhaust fan speed (% of max speed)."""
    return _parse_fan_speed(SZ_EXHAUST_FAN_SPEED, value)  # type: ignore[return-value]


# 31DA[40:42]
def parse_supply_fan_speed(value: HexStr2) -> PayDictT.SUPPLY_FAN_SPEED:
    """Return the supply fan speed (% of max speed)."""
    return _parse_fan_speed(SZ_SUPPLY_FAN_SPEED, value)  # type: ignore[return-value]


def _parse_fan_speed(param_name: str, value: HexStr2) -> Mapping[str, float | None]:
    """Return the fan speed (called by sensor parsers).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 2:
        raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")

    if value == "FF":  # Not implemented (is definitely FF, not EF!)
        return {param_name: None}

    percentage = int(value, 16) / 200  # was: hex_to_percent(value)
    if percentage > 1.0:
        return _faulted_common(param_name, value)  # type: ignore[return-value]

    return {param_name: percentage}


# 31DA[42:46] & 22F3[2:6]  # TODO: make 22F3-friendly
def parse_remaining_mins(value: HexStr4) -> PayDictT.REMAINING_MINUTES:
    """Return the remaining time for temporary modes (whole minutes).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 4:
        raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")

    if value == "0000":
        return {SZ_REMAINING_MINS: 0}
    if value == "3FFF":
        return {SZ_REMAINING_MINS: None}

    minutes = int(value, 16)  # was: hex_to_double(value)
    assert minutes > 0, value  # TODO: raise assert

    return {SZ_REMAINING_MINS: minutes}  # usu. 0-60 mins


# 31DA[46:48]
def parse_post_heater(value: HexStr2) -> PayDictT.POST_HEATER:
    """Return the post-heater state (% of max heat)."""
    return _parse_fan_heater(SZ_POST_HEAT, value)  # type: ignore[return-value]


# 31DA[48:50]
def parse_pre_heater(value: HexStr2) -> PayDictT.PRE_HEATER:
    """Return the pre-heater state (% of max heat)."""
    return _parse_fan_heater(SZ_PRE_HEAT, value)  # type: ignore[return-value]


def _parse_fan_heater(param_name: str, value: HexStr2) -> Mapping[str, float | None]:
    """Return the heater state (called by sensor parsers).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 2:
        raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")

    if value == "EF":  # Not implemented
        return {param_name: None}

    if int(value, 16) & 0xF0 == 0xF0:
        return _faulted_sensor(param_name, value)  # type: ignore[return-value]

    percentage = int(value, 16) / 100
    assert percentage <= 1.0, value  # TODO: raise exception if > 1.0?

    return {param_name: percentage}  # was: percent_from_hex(value, high_res=False)


# 31DA[50:54]
def parse_supply_flow(value: HexStr4) -> PayDictT.SUPPLY_FLOW:
    """Return the supply flow rate in m^3/hr (Orcon) ?or L/sec (?Itho)."""
    return _parse_fan_flow(SZ_SUPPLY_FLOW, value)  # type: ignore[return-value]


# 31DA[54:58]
def parse_exhaust_flow(value: HexStr4) -> PayDictT.EXHAUST_FLOW:
    """Return the exhuast flow rate in m^3/hr (Orcon) ?or L/sec (?Itho)"""
    return _parse_fan_flow(SZ_EXHAUST_FLOW, value)  # type: ignore[return-value]


def _parse_fan_flow(param_name: str, value: HexStr4) -> Mapping[str, float | None]:
    """Return the air flow rate (called by sensor parsers).

    The sensor value is None if there is no sensor present (is not an error).
    The dict does not include the key if there is a sensor fault.
    """

    # TODO: remove this...
    if not isinstance(value, str) or len(value) != 4:
        raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")

    if value == "7FFF":  # Not implemented
        return {param_name: None}

    if int(value[:2], 16) & 0x80:
        return _faulted_sensor(param_name, value)  # type: ignore[return-value]

    flow = int(value, 16) / 100  # was: hex_to_double(value, factor=100)
    assert flow >= 0, value  # TODO: raise exception if < 0?

    return {param_name: flow}
