"""Helper functions for VeSync API."""

from __future__ import annotations

import hashlib
import logging
import re
import time
from collections.abc import Iterator
from dataclasses import InitVar, dataclass, field
from enum import StrEnum
from functools import lru_cache
from typing import TYPE_CHECKING, Any, TypeVar

from mashumaro.exceptions import InvalidFieldValue, MissingField, UnserializableField
from mashumaro.mixins.orjson import DataClassORJSONMixin
from typing_extensions import deprecated

from pyvesync.const import (
    APP_VERSION,
    BYPASS_HEADER_UA,
    DEFAULT_REGION,
    KELVIN_MAX,
    KELVIN_MIN,
    MOBILE_ID,
    PHONE_BRAND,
    PHONE_OS,
    USER_TYPE,
    ConnectionStatus,
)
from pyvesync.models.base_models import DefaultValues
from pyvesync.utils.errors import ErrorCodes, ErrorTypes, ResponseInfo
from pyvesync.utils.logs import LibraryLogger

if TYPE_CHECKING:
    from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
    from pyvesync.vesync import VeSync


T = TypeVar('T')
T_MODEL = TypeVar('T_MODEL', bound=DataClassORJSONMixin)

ST = TypeVar('ST', str, int)

_LOGGER = logging.getLogger(__name__)

NUMERIC_OPT = float | str | None

NUMERIC_STRICT = float | str

REQUEST_T = dict[str, Any]


class Validators:
    """Methods to validate input."""

    @staticmethod
    def validate_range(
        value: NUMERIC_OPT, minimum: NUMERIC_STRICT, maximum: NUMERIC_STRICT
    ) -> bool:
        """Validate number is within range."""
        if value is None:
            return False
        try:
            return float(minimum) <= float(value) <= float(maximum)
        except (ValueError, TypeError):
            return False

    @classmethod
    def validate_zero_to_hundred(cls, value: NUMERIC_OPT) -> bool:
        """Validate number is a percentage."""
        return Validators.validate_range(value, 0, 100)

    @classmethod
    def validate_hsv(
        cls, hue: NUMERIC_OPT, saturation: NUMERIC_OPT, value: NUMERIC_OPT
    ) -> bool:
        """Validate HSV values."""
        return (
            cls.validate_range(hue, 0, 360)
            and cls.validate_zero_to_hundred(saturation)
            and cls.validate_zero_to_hundred(value)
        )

    @classmethod
    def validate_rgb(
        cls, red: NUMERIC_OPT, green: NUMERIC_OPT, blue: NUMERIC_OPT
    ) -> bool:
        """Validate RGB values."""
        return all(cls.validate_range(val, 0, 255) for val in (red, green, blue))


class Converters:
    """Helper functions to convert units."""

    @staticmethod
    def color_temp_kelvin_to_pct(kelvin: int) -> int:
        """Convert Kelvin to percentage."""
        return int((kelvin - KELVIN_MIN) / (KELVIN_MAX - KELVIN_MIN) * 100)

    @staticmethod
    def color_temp_pct_to_kelvin(pct: int) -> int:
        """Convert percentage to Kelvin."""
        return int(KELVIN_MIN + ((pct / 100) * (KELVIN_MAX - KELVIN_MIN)))

    @staticmethod
    def temperature_kelvin_to_celsius(kelvin: int) -> float:
        """Convert Kelvin to Celsius."""
        return kelvin - 273.15

    @staticmethod
    def temperature_celsius_to_kelvin(celsius: float) -> int:
        """Convert Celsius to Kelvin."""
        return int(celsius + 273.15)

    @staticmethod
    def temperature_fahrenheit_to_celsius(fahrenheit: float) -> float:
        """Convert Fahrenheit to Celsius."""
        return (fahrenheit - 32) * 5.0 / 9.0

    @staticmethod
    def temperature_celsius_to_fahrenheit(celsius: float) -> float:
        """Convert Celsius to Fahrenheit."""
        return celsius * 9.0 / 5.0 + 32


class Helpers:
    """VeSync Helper Functions."""

    @staticmethod
    def model_maker(
        logger: logging.Logger,
        model: type[T_MODEL],
        method_name: str,
        data: dict[str, Any],
        device: VeSyncBaseDevice | None = None,
    ) -> T_MODEL | None:
        """Create a model instance from a dictionary.

        This method catches common errors that occur when parsing the
        API response and returns None if the data is invalid. Enable debug
        or verbose logging to see more information.


        Args:
            logger (logging.Logger): Logger instance.
            model (type[T_MODEL]): Model class to create an instance of.
            method_name (str): Name of the method used in API call.
            device (VeSyncBaseDevice): Instance of VeSyncBaseDevice.
            data (dict[str, Any] | None): Dictionary to create the model from.

        Returns:
            T_MODEL: Instance of the model class.
        """
        try:
            model_instance = model.from_dict(data)
        except (MissingField, UnserializableField, InvalidFieldValue) as err:
            LibraryLogger.error_mashumaro_response(
                logger,
                method_name,
                data,
                err,
                device,
            )
            return None
        return model_instance

    @staticmethod
    def bump_level(level: T | None, levels: list[T]) -> T:
        """Increment level by one returning to first level if at last.

        Args:
            level (T | None): Current level.
            levels (list[T]): List of levels.
        """
        if level in levels:
            idx = levels.index(level)
            if idx < len(levels) - 1:
                return levels[idx + 1]
        return levels[0]

    @classmethod
    def process_dev_response(
        cls,
        logger: logging.Logger,
        method_name: str,
        device: VeSyncBaseDevice,
        r_dict: dict | None,
    ) -> dict | None:
        """Process JSON response from Bytes.

        Parses bytes and checks for errors common to all JSON
        responses, included checking the "code" key for non-zero
        values. Outputs error to passed logger with formatted string
        if an error is found. This also saves the response code information
        to the `device.last_response` attribute.

        Args:
            logger (logging.Logger): Logger instance.
            method_name (str): Method used in API call.
            r_dict (dict | None): JSON response from API.
            device (VeSyncBaseDevice): Instance of VeSyncBaseDevice.

        Returns:
            dict | None: Parsed JSON response or None if there was an error.
        """
        device.state.update_ts()
        if r_dict is None:
            LibraryLogger.error_device_response_content(
                logger,
                device,
                method_name,
                'No response or cannot deserialize response from API',
            )
            device.last_response = ResponseInfo(
                name='INVALID_RESPONSE',
                error_type=ErrorTypes.BAD_RESPONSE,
                message=f'No response from API for {method_name}',
            )
            return None

        error_info = cls.parse_error_code(r_dict)
        if error_info.device_online is False:
            device.state.connection_status = ConnectionStatus.OFFLINE
        else:
            device.state.connection_status = ConnectionStatus.ONLINE
        LibraryLogger.log_device_return_code(
            logger,
            method_name,
            device.device_name,
            device.device_type,
            error_info,
        )
        device.last_response = error_info
        device.last_response.response_data = r_dict
        if error_info.code != 0:
            return None
        return r_dict

    @staticmethod
    def get_class_attributes(target_class: object, keys: tuple[str]) -> dict[str, Any]:
        """Find matching attributes, static methods, and class methods from list of keys.

        This function is case insensitive and will remove underscores from the keys before
        comparing them to the class attributes. The provided keys will be returned in the
        same format if found

        Args:
            target_class (object): Class to search for attributes
            keys (tuple[str]): Tuple of keys to search for

        Returns:
            dict[str, Any]: Dictionary of keys and their values from the class
        """
        alias_map = {
            'userCountryCode': 'countrycode',
            'deviceId': 'cid',
            'homeTimeZone': 'timezone',
            'configModel': 'configmodule',
            'region': 'countrycode',
        }

        def normalize_name(name: str) -> str:
            """Normalize a string by removing underscores and making it lowercase."""
            return re.sub(r'_', '', name).lower()

        def get_value(attr_name: str) -> str | float | None:
            """Get value from attribute."""
            attr = getattr(target_class, attr_name)
            try:
                return attr() if callable(attr) else attr  # type: ignore[no-any-return]
            except TypeError:
                return None

        result = {}
        normalized_keys = {normalize_name(key): key for key in keys}
        normalized_aliases = [normalize_name(key) for key in alias_map.values()]

        for attr_name in dir(target_class):
            normalized_attr = normalize_name(attr_name)
            if normalized_attr in normalized_keys:
                attr_val = get_value(attr_name)
                if attr_val is not None:
                    result[normalized_keys[normalized_attr]] = attr_val
            if normalized_attr in normalized_aliases:
                attr_val = get_value(attr_name)
                if attr_val is not None:
                    key_index = normalized_aliases.index(normalized_attr)
                    key_val = list(alias_map.keys())[key_index]
                    if key_val in keys:
                        result[key_val] = attr_val

        return result

    @classmethod
    @lru_cache(maxsize=128, typed=True)
    def get_defaultvalues_attributes(cls, keys: tuple[str]) -> dict[str, Any]:
        """Get default values for dataclass attributes.

        This method uses lru_cache to cache results for faster subsequent lookups.

        Args:
            keys (list[str]): List of attribute names to get default values for.

        Returns:
            dict[str, Any]: Dictionary of attribute names and their default values.
        """
        return cls.get_class_attributes(DefaultValues, keys)

    @classmethod
    @lru_cache(maxsize=128, typed=True)
    def get_manager_attributes(cls, manager: VeSync, keys: tuple[str]) -> dict[str, Any]:
        """Get VeSync manager attributes.

        This method uses lru_cache to cache results for faster subsequent lookups.

        Args:
            manager (VeSyncManager): Instance of VeSyncManager.
            keys (list[str]): List of attribute names to get values for.

        Returns:
            dict[str, Any]: Dictionary of attribute names and their values.
        """
        return cls.get_class_attributes(manager, keys)

    @classmethod
    @lru_cache(maxsize=128, typed=True)
    def get_device_attributes(
        cls, device: VeSyncBaseDevice, keys: tuple[str]
    ) -> dict[str, Any]:
        """Get VeSync device attributes.

        This method uses lru_cache to cache results for faster subsequent lookups.

        Args:
            device (VeSyncBaseDevice): Instance of VeSyncBaseDevice.
            keys (list[str]): List of attribute names to get values for.

        Returns:
            dict[str, Any]: Dictionary of attribute names and their values.
        """
        return cls.get_class_attributes(device, keys)

    @staticmethod
    def req_legacy_headers(manager: VeSync) -> dict[str, str]:
        """Build header for legacy api GET requests.

        Args:
            manager (VeSyncManager): Instance of VeSyncManager.

        Returns:
            dict: Header dictionary for api requests.

        Examples:
            >>> req_headers(manager)
            {
                'accept-language': 'en',
                'accountId': manager.account_id,
                'appVersion': APP_VERSION,
                'content-type': 'application/json',
                'tk': manager.token,
                'tz': manager.time_zone,
            }

        """
        return {
            'accept-language': 'en',
            'accountId': manager.account_id,  # type: ignore[dict-item]
            'appVersion': APP_VERSION,
            'content-type': 'application/json',
            'tk': manager.token,  # type: ignore[dict-item]
            'tz': manager.time_zone,
        }

    @staticmethod
    def req_header_bypass() -> dict[str, str]:
        """Build header for api requests on 'bypass' endpoint.

        Returns:
            dict: Header dictionary for api requests.

        Examples:
            >>> req_header_bypass()
            {
                'Content-Type': 'application/json; charset=UTF-8',
                'User-Agent': BYPASS_HEADER_UA,
            }
        """
        return {
            'Content-Type': 'application/json; charset=UTF-8',
            'User-Agent': BYPASS_HEADER_UA,
        }

    @staticmethod
    def _req_body_base(manager: VeSync) -> dict[str, str]:
        """Return universal keys for body of api requests.

        Args:
            manager (VeSyncManager): Instance of VeSyncManager.

        Returns:
            dict: Body dictionary for api requests.

        Examples:
            >>> req_body_base(manager)
            {
                'timeZone': manager.time_zone,
                'acceptLanguage': 'en',
            }
        """
        return {'timeZone': manager.time_zone, 'acceptLanguage': 'en'}

    @staticmethod
    def _req_body_auth(manager: VeSync) -> REQUEST_T:
        """Keys for authenticating api requests.

        Args:
            manager (VeSyncManager): Instance of VeSyncManager.

        Returns:
            dict: Authentication keys for api requests.

        Examples:
            >>> req_body_auth(manager)
            {
                'accountID': manager.account_id,
                'token': manager.token,
            }
        """
        return {'accountID': manager.account_id, 'token': manager.token}

    @staticmethod
    @deprecated('This is a legacy function and will be removed in a future release.')
    def _req_body_details() -> REQUEST_T:
        """Detail keys for api requests.

        This method is deprecated, use `get_class_attributes` instead.

        Returns:
            dict: Detail keys for api requests.

        Examples:
            >>> req_body_details()
            {
                'appVersion': APP_VERSION,
                'phoneBrand': PHONE_BRAND,
                'phoneOS': PHONE_OS,
                'traceId': str(int(time.time())),
            }
        """
        return {
            'appVersion': APP_VERSION,
            'phoneBrand': PHONE_BRAND,
            'phoneOS': PHONE_OS,
            'traceId': str(int(time.time())),
        }

    @classmethod
    @deprecated('This is a legacy function and will be removed in a future release.')
    def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T:  # noqa: C901
        """Builder for body of api requests.

        This method is deprecated, use `get_class_attributes` instead.

        Args:
            manager (VeSyncManager): Instance of VeSyncManager.
            type_ (str): Type of request to build body for.

        Returns:
            dict: Body dictionary for api requests.

        Note:
            The body dictionary will be built based on the type of request.
            The type of requests include:
            - login
            - devicestatus
            - devicelist
            - devicedetail
            - energy_week
            - energy_month
            - energy_year
            - bypass
            - bypassV2
            - bypass_config
        """
        body: REQUEST_T = cls._req_body_base(manager)

        if type_ == 'login':
            body |= cls._req_body_details()
            # pylint: disable=protected-access
            body |= {
                'email': manager.auth._username,  # noqa: SLF001
                'password': cls.hash_password(
                    manager.auth._password  # noqa: SLF001
                ),
                'devToken': '',
                'userType': USER_TYPE,
                'method': 'login',
            }
            return body

        body |= cls._req_body_auth(manager)

        if type_ == 'devicestatus':
            return body

        body |= cls._req_body_details()

        if type_ == 'devicelist':
            body['method'] = 'devices'
            body['pageNo'] = '1'
            body['pageSize'] = '100'

        elif type_ == 'devicedetail':
            body['method'] = 'devicedetail'
            body['mobileId'] = MOBILE_ID

        elif type_ == 'energy_week':
            body['method'] = 'energyweek'
            body['mobileId'] = MOBILE_ID

        elif type_ == 'energy_month':
            body['method'] = 'energymonth'
            body['mobileId'] = MOBILE_ID

        elif type_ == 'energy_year':
            body['method'] = 'energyyear'
            body['mobileId'] = MOBILE_ID

        elif type_ == 'bypass':
            body['method'] = 'bypass'

        elif type_ == 'bypassV2':
            body['deviceRegion'] = DEFAULT_REGION
            body['method'] = 'bypassV2'

        elif type_ == 'bypass_config':
            body['method'] = 'firmwareUpdateInfo'

        return body

    @staticmethod
    def calculate_hex(hex_string: str) -> float:
        """Credit for conversion to itsnotlupus/vesync_wsproxy.

        Hex conversion for legacy outlet power and voltage.
        """
        hex_conv = hex_string.split(':')
        return (int(hex_conv[0], 16) + int(hex_conv[1], 16)) / 8192

    @staticmethod
    def hash_password(string: str) -> str:
        """Encode password."""
        return hashlib.md5(string.encode('utf-8')).hexdigest()  # noqa: S324

    @staticmethod
    def parse_error_code(response: dict) -> ResponseInfo:  # noqa: C901
        """Get all error codes from nested dictionary.

        Args:
            response (dict): API response.

        Returns:
            list[int]: List of error codes.
        """
        error_keys = ['error', 'code', 'error_code', 'errorCode']

        def extract_all_error_codes(
            key: str, var: dict
        ) -> Iterator[tuple[int, str | None]]:
            """Find all error code keys in nested dictionary."""
            if hasattr(var, 'items'):
                for k, v in var.items():
                    if k == key and isinstance(v, int) and v != 0:
                        msg = var.get('msg') or var.get('result', {}).get('msg')
                        yield v, msg
                    if isinstance(v, dict):
                        yield from extract_all_error_codes(key, v)
                    elif isinstance(v, list):
                        for item in v:
                            yield from extract_all_error_codes(key, item)

        errors: list[tuple[int, str | None]] = []

        if 'code' in response:
            outside_code = response.get('code', '')
            outside_msg = response.get('msg')

            if str(outside_code) != '0':
                return ErrorCodes.get_error_info(int(outside_code), outside_msg)

        for error_key in error_keys:
            errors.extend(list(extract_all_error_codes(error_key, response)))
        return_code = 0
        return_msg = None
        for code, msg in errors:
            if code != 0:
                return_code = code
                return_msg = msg
        return ErrorCodes.get_error_info(int(return_code), return_msg)

    @staticmethod
    def get_key(
        data: dict[StrEnum | str, ST], value: ST, default: str | None = None
    ) -> str | StrEnum | None:
        """Get key from dictionary ignoring case sensitivity.

        Args:
            data (dict[str, Any]): Dictionary to search.
            value (str): Value to search for.
            default (Any): Default value to return if key not found.

        Returns:
            Any: Value associated with the key, or None if not found.
        """
        return next((k for k, v in data.items() if v == value), default)


@dataclass(repr=False)
class Timer:
    """Dataclass to hold state of timers.

    Note:
        This should be used by VeSync device instances to manage internal status,
        does not interact with the VeSync API.

    Args:
        timer_duration (int): Length of timer in seconds
        action (str): Action to perform when timer is done
        id (int): ID of timer, defaults to 1
        remaining (int): Time remaining on timer in seconds, defaults to None
        _update_time (int): Last updated unix timestamp in seconds, defaults to None

    Attributes:
        update_time (str): Timestamp of last update
        status (str): Status of timer, one of 'active', 'paused', 'done'
        time_remaining (int): Time remaining on timer in seconds
        running (bool): True if timer is running
        paused (bool): True if timer is paused
        done (bool): True if timer is done
    """

    timer_duration: int
    action: str
    id: int = 1
    remaining: InitVar[int | None] = None
    _status: str = field(default='active', init=False, repr=False)
    _remain: int = field(default=0, init=False, repr=False)
    _update_time: int = int(time.time())

    def __post_init__(self, remaining: int | None) -> None:
        """Set remaining time if provided."""
        if remaining is not None:
            self._remain = remaining
        else:
            self._remain = self.timer_duration

    def __repr__(self) -> str:
        """Return string representation of the Timer object.

        Returns:
            str: String representation of the Timer object.
        """
        return (
            f'Timer(id={self.id}, duration={self.timer_duration}, '
            f'status={self.status}, remaining={self.time_remaining})'
        )

    def update_ts(self) -> None:
        """Update timestamp."""
        self._update_time = int(time.time())

    @property
    def status(self) -> str:
        """Return status of timer."""
        if self._status in ('paused', 'done'):
            return self._status
        if self.time_remaining <= 0:
            self._status = 'done'
            return 'done'
        return 'active'

    @property
    def time_remaining(self) -> int:
        """Return remaining seconds."""
        if self._status == 'paused':
            return self._remain
        if self._status == 'done':
            return 0

        # 'active' state - compute how much time has ticked away
        elapsed = time.time() - self._update_time
        current_remaining = self._remain - elapsed

        # If we've run out of time, mark it done
        if current_remaining <= 0:
            return 0
        return int(current_remaining)

    @property
    def running(self) -> bool:
        """Check if timer is active."""
        return self.time_remaining > 0 and self.status == 'active'

    @property
    def paused(self) -> bool:
        """Check if timer is paused."""
        return bool(self.status == 'paused')

    @property
    def done(self) -> bool:
        """Check if timer is complete."""
        return bool(self.time_remaining <= 0 or self._status == 'done')

    def end(self) -> None:
        """Change status of timer to done."""
        self._status = 'done'
        self._remain = 0

    def start(self) -> None:
        """Restart paused timer."""
        if self._status != 'paused':
            return
        self._update_time = int(time.time())
        self._status = 'active'

    def pause(self) -> None:
        """Pauses the timer if it's active.

        Performs the following steps:
            - Calculate the up-to-date remaining time,
            - Update internal counters,
            - Set _status to 'paused'.
        """
        if self._status == 'active':
            # Update the time_remaining based on elapsed
            current_remaining = self.time_remaining
            if current_remaining <= 0:
                self._status = 'done'
                self._remain = 0
            else:
                self._status = 'paused'
                self._remain = current_remaining
            self._update_time = int(time.time())


@dataclass(kw_only=True)
class OscillationCoordinates:
    """Dataclass to hold oscillation coordinates.

    Note:
        This should be used by VeSync device instances to manage internal status,
        does not interact with the VeSync API.
    """

    yaw: int
    pitch: int


@dataclass(kw_only=True)
class OscillationRange:
    """Dataclass to hold oscillation range.

    Note:
        This should be used by VeSync device instances to manage internal status,
        does not interact with the VeSync API.
    """

    left: int
    right: int
    top: int
    bottom: int
