from __future__ import annotations

import asyncio
import time

from typing import Optional, Any, Dict

from .box_types import _DEFAULT_API_LEVEL, get_conf, get_conf_set
from .button import Button
from .climate import Climate
from .cover import Cover
from .light import Light
from .sensor import SensorFactory
from .binary_sensor import BinarySensor
from .session import ApiHost
from .switch import Switch
from .update import Update

from .error import (
    UnsupportedBoxResponse,
    UnsupportedBoxVersion,
    BadFieldExceedsMax,
    BadFieldLessThanMin,
    BadFieldMissing,
    BadFieldNotANumber,
    BadFieldNotAString,
    BadFieldNotRGBW,
    HttpError,
)

DEFAULT_PORT = 80


class Box:
    _name: str
    _data_path: str
    _last_real_update: Optional[float]
    _last_data: Optional[Any]

    api_session: ApiHost
    info: dict
    config: dict
    extended_state: Optional[Dict[Any, Any]]

    def __init__(
        self,
        api_session,
        info,
        config,
        extended_state,
    ) -> None:
        self._last_data = None
        self._last_real_update = None
        self._sem = asyncio.BoundedSemaphore()
        self._session = api_session

        address = f"{api_session.host}:{api_session.port}"

        location = f"Device at {address}"

        # NOTE: get ID first for better error messages
        try:
            unique_id = info["id"]
        except KeyError as ex:
            raise UnsupportedBoxResponse(info, f"{location} has no id") from ex
        location = f"Device:{unique_id} at {address}"

        try:
            type = info["type"]
        except KeyError as ex:
            raise UnsupportedBoxResponse(info, f"{location} has no type") from ex

        try:
            product = info["product"]
        except KeyError:
            product = type

        # TODO: make wLightBox API support multiple products
        # in 2020 wLightBoxS API has been deprecated and it started using wLightBox API
        # current codebase needs a refactor to support multiple product sharing one API
        # as a temporary workaround we are using 'alias' type wLightBoxS2
        if type == "wLightBox" and product == "wLightBoxS":
            type = "wLightBoxS"

        location = f"{product}:{unique_id} at {address}"

        try:
            name = info["deviceName"]
        except KeyError as ex:
            raise UnsupportedBoxResponse(info, f"{location} has no name") from ex
        location = f"'{name}' ({product}:{unique_id} at {address})"

        try:
            firmware_version = info["fv"]
        except KeyError as ex:
            raise UnsupportedBoxResponse(
                info, f"{location} has no firmware version"
            ) from ex
        location = f"'{name}' ({product}:{unique_id}/{firmware_version} at {address})"

        try:
            hardware_version = info["hv"]
        except KeyError as ex:
            raise UnsupportedBoxResponse(
                info, f"{location} has no hardware version"
            ) from ex

        available_firmware_version = info.get("availableFv")

        level = int(info.get("apiLevel", _DEFAULT_API_LEVEL))

        self._data_path = config["api_path"]
        self._type = type
        self._unique_id = unique_id
        self._name = name
        self._address = address
        self._firmware_version = firmware_version
        self._hardware_version = hardware_version
        self._available_firmware_version = available_firmware_version
        self._api_version = level
        self._model = config.get("model", type)
        self._api = config.get("api", {})
        self._features = self.create_features(config, info, extended_state)
        self._config = config
        self._update_last_data(extended_state)

    def create_features(
        self, config: dict, info: dict, extended_state: Optional[dict]
    ) -> dict:
        features = {}
        for field, klass in {
            "covers": Cover,
            "sensors": SensorFactory,
            "binary_sensors": BinarySensor,
            "lights": Light,
            "climates": Climate,
            "switches": Switch,
            "buttons": Button,
        }.items():
            try:
                if box_type_config := config.get(field):
                    features[field] = klass.many_from_config(
                        self,
                        box_type_config=box_type_config,
                        extended_state=extended_state,
                    )
            except KeyError:
                raise UnsupportedBoxResponse("Failed to initialize:", info)
        features["updates"] = [Update(self, "update", {})]
        return features

    @classmethod
    async def async_from_host(cls, api_host: ApiHost) -> Box:
        try:
            path = "/api/device/state"
            data = await api_host.async_api_get(path)
        except HttpError:
            path = "/info"
            data = await api_host.async_api_get(path)
        if data is None:
            raise UnsupportedBoxResponse("Device returned non-JSON response")
        info = data.get("device", data)
        extended_state = None

        config = cls._match_device_config(info)
        if extended_state_path := config.get("extended_state_path"):
            try:
                extended_state = await api_host.async_api_get(extended_state_path)
            except (HttpError, KeyError):
                extended_state = None

        return cls(api_host, info, config, extended_state)

    @classmethod
    def _match_device_config(cls, info: dict) -> dict:
        try:
            device_type = info["type"]
        except KeyError as ex:
            raise UnsupportedBoxResponse(info, "Device info has no type key") from ex
        try:
            product = info["product"]
        except KeyError:
            product = device_type
        if device_type == "wLightBox" and product == "wLightBoxS":
            device_type = "wLightBoxS"
        level = int(info.get("apiLevel", _DEFAULT_API_LEVEL))
        config_set = get_conf_set(device_type)
        if not config_set:
            raise UnsupportedBoxResponse(f"{device_type} is not a supported type")
        config = get_conf(level, config_set)
        if not config:
            raise UnsupportedBoxVersion(
                f"{device_type} has unsupported version ({level})."
            )

        return config

    @property
    def name(self) -> str:
        return self._name

    @property
    def address(self) -> str:
        return self._address

    @property
    def last_data(self) -> Optional[Dict[Any, Any]]:
        return self._last_data

    # Used in full_name, track down and refactor.
    @property
    def type(self) -> str:
        return self._type

    @property
    def unique_id(self) -> Any:
        return self._unique_id

    @property
    def firmware_version(self) -> Any:
        return self._firmware_version

    @property
    def hardware_version(self) -> Any:
        return self._hardware_version

    @property
    def available_firmware_version(self) -> Any:
        return self._available_firmware_version

    @property
    def api_version(self) -> int:
        return self._api_version

    @property
    def features(self) -> dict:
        return self._features

    @property
    def brand(self) -> str:
        return "BleBox"

    @property
    def model(self) -> Any:
        return self._model

    async def async_update_data(self) -> None:
        await self._async_api(True, "GET", self._data_path)

    def _update_last_data(self, new_data: Optional[dict]) -> None:
        # Note: on certain more complex devices that inlcude multiple features
        # like switches and sensors (e.g. SwitchboxD) it may happen that activating
        # single feature would result only in partial update of the self._last_data.
        # We can know that by comparing keys of both states.
        #
        # Notable example of device exhibiting this behavior is SwitchboxD (20200831)
        # It has two relays (switches) and power measurement capabilities (sensor).
        # Toggling the relay does return state of all relays, but does not return
        # sensor information (partial state). Accepting the new state as-is would
        # break the update of sensory information.
        #
        # Note that SwitchboxD is just an example. It is possible that APIs of other
        # box types also exhibit this kind of behavior.
        if (
            isinstance(self._last_data, dict)
            and isinstance(new_data, dict)
            and self._last_data.keys() != new_data.keys()
        ):
            # ... In such a case we need to merge both states instead of overwriting
            # the old one as-is.
            #
            # The only risk is that if certain features are somehow coupled inside
            # the device, we will have inconsistent information about the device.
            # However, this should be eventually consistent as new updates arrive.
            # In the end, it is better to have an inconsistent state, rather than
            # have broken one (e.g. missing keys) that results in broken features.
            #
            # Refs:
            # - https://github.com/blebox/blebox_uniapi/pull/152
            # - https://github.com/blebox/blebox_uniapi/issues/137
            new_data = {**self._last_data, **new_data}

        self._last_data = new_data
        for feature_set in self._features.values():
            for feature in feature_set:
                feature.after_update()

    async def async_api_command(self, command: str, value: Any = None) -> None:
        method, *args = self._api[command](value)
        self._last_real_update = None  # force update
        return await self._async_api(False, method, *args)

    def expect_int(
        self, field: str, raw_value: int, maximum: int = -1, minimum: int = 0
    ) -> int:
        return self.check_int(raw_value, field, maximum, minimum)

    def expect_hex_str(
        self, field: str, raw_value: int, maximum: int = -1, minimum: int = 0
    ) -> int:
        return self.check_hex_str(raw_value, field, maximum, minimum)

    def expect_rgbw(self, field: str, raw_value: int) -> int:
        return self.check_rgbw(raw_value, field)

    def check_int_range(
        self, value: int, field: str, max_value: int, min_value: int
    ) -> int:
        if max_value >= min_value:
            if value > max_value:
                raise BadFieldExceedsMax(self.name, field, value, max_value)
            if value < min_value:
                raise BadFieldLessThanMin(self.name, field, value, min_value)
        return value

    def check_int(self, value: int, field: str, max_value: int, min_value: int) -> int:
        if value is None:
            raise BadFieldMissing(self.name, field)

        if not isinstance(value, int):
            raise BadFieldNotANumber(self.name, field, value)

        return self.check_int_range(value, field, max_value, min_value)

    def check_hex_str(
        self, value: int, field: str, max_value: int, min_value: int
    ) -> int:
        if value is None:
            raise BadFieldMissing(self.name, field)

        if not isinstance(value, str):
            raise BadFieldNotAString(self.name, field, value)

        if (
            self.check_int_range(int(value, 16), field, max_value, min_value)
            is not None
        ):
            return value

    def check_rgbw(self, value: int, field: str) -> int:
        if value is None:
            raise BadFieldMissing(self.name, field)

        if not isinstance(value, str):
            raise BadFieldNotAString(self.name, field, value)

        if len(value) > 10 or len(value) % 2 != 0:
            raise BadFieldNotRGBW(self.name, field, value)
        return value

    def _has_recent_data(self) -> bool:
        last = self._last_real_update
        return (time.time() - 2) <= last if last is not None else False

    async def _async_api(
        self,
        is_update: bool,
        method: Any,
        path: str,
        post_data: dict = None,
    ) -> None:
        if method not in ("GET", "POST"):
            raise NotImplementedError(method)  # pragma: no cover

        if is_update:
            if self._has_recent_data():
                return

        async with self._sem:
            if is_update:
                if self._has_recent_data():
                    return
            if method == "GET":
                response = await self._session.async_api_get(path)
            else:
                response = await self._session.async_api_post(path, post_data)
            self._update_last_data(response)
            self._last_real_update = time.time()

    async def async_ota_check(self) -> None:
        await self._session.async_api_get_ota("/api/ota/check")
        for _ in range(3):
            await asyncio.sleep(1)
            response = await self._session.async_api_get("/info")
            if response is None:
                raise UnsupportedBoxResponse("Device returned non-JSON response")
            response = response.get("device", response)
            if response.get("availableFv") is not None:
                self._firmware_version = response.get("fv", self._firmware_version)
                self._available_firmware_version = response["availableFv"]
                return

    async def async_ota_update(self) -> None:
        await self._session.async_api_get("/api/ota/update")
