"""Support for the Philips Hue sensors as a platform."""
import asyncio
from datetime import timedelta
import logging
from time import monotonic

from aiohue import AiohueException, Unauthorized
from aiohue.sensors import TYPE_ZLL_PRESENCE
import async_timeout

from homeassistant.components import hue
from homeassistant.exceptions import NoEntitySpecifiedError
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow

from .helpers import remove_devices

CURRENT_SENSORS_FORMAT = "{}_current_sensors"
SENSOR_MANAGER_FORMAT = "{}_sensor_manager"

_LOGGER = logging.getLogger(__name__)


def _device_id(aiohue_sensor):
    # Work out the shared device ID, as described below
    device_id = aiohue_sensor.uniqueid
    if device_id and len(device_id) > 23:
        device_id = device_id[:23]
    return device_id


async def async_setup_entry(hass, config_entry, async_add_entities, binary=False):
    """Set up the Hue sensors from a config entry."""
    sensor_key = CURRENT_SENSORS_FORMAT.format(config_entry.data["host"])
    bridge = hass.data[hue.DOMAIN][config_entry.data["host"]]
    hass.data[hue.DOMAIN].setdefault(sensor_key, {})

    sm_key = SENSOR_MANAGER_FORMAT.format(config_entry.data["host"])
    manager = hass.data[hue.DOMAIN].get(sm_key)
    if manager is None:
        manager = SensorManager(hass, bridge, config_entry)
        hass.data[hue.DOMAIN][sm_key] = manager

    manager.register_component(binary, async_add_entities)
    await manager.start()


class SensorManager:
    """Class that handles registering and updating Hue sensor entities.

    Intended to be a singleton.
    """

    SCAN_INTERVAL = timedelta(seconds=5)
    sensor_config_map = {}

    def __init__(self, hass, bridge, config_entry):
        """Initialize the sensor manager."""
        self.hass = hass
        self.bridge = bridge
        self.config_entry = config_entry
        self._component_add_entities = {}
        self._started = False

    def register_component(self, binary, async_add_entities):
        """Register async_add_entities methods for components."""
        self._component_add_entities[binary] = async_add_entities

    async def start(self):
        """Start updating sensors from the bridge on a schedule."""
        # but only if it's not already started, and when we've got both
        # async_add_entities methods
        if self._started or len(self._component_add_entities) < 2:
            return

        self._started = True
        _LOGGER.info(
            "Starting sensor polling loop with %s second interval",
            self.SCAN_INTERVAL.total_seconds(),
        )

        async def async_update_bridge(now):
            """Will update sensors from the bridge."""

            # don't update when we are not authorized
            if not self.bridge.authorized:
                return

            await self.async_update_items()

            async_track_point_in_utc_time(
                self.hass, async_update_bridge, utcnow() + self.SCAN_INTERVAL
            )

        await async_update_bridge(None)

    async def async_update_items(self):
        """Update sensors from the bridge."""
        api = self.bridge.api.sensors

        try:
            start = monotonic()
            with async_timeout.timeout(4):
                await api.update()
        except Unauthorized:
            await self.bridge.handle_unauthorized_error()
            return
        except (asyncio.TimeoutError, AiohueException) as err:
            _LOGGER.debug("Failed to fetch sensor: %s", err)

            if not self.bridge.available:
                return

            _LOGGER.error("Unable to reach bridge %s (%s)", self.bridge.host, err)
            self.bridge.available = False

            return

        finally:
            _LOGGER.debug(
                "Finished sensor request in %.3f seconds", monotonic() - start
            )

        if not self.bridge.available:
            _LOGGER.info("Reconnected to bridge %s", self.bridge.host)
            self.bridge.available = True

        new_sensors = []
        new_binary_sensors = []
        primary_sensor_devices = {}
        sensor_key = CURRENT_SENSORS_FORMAT.format(self.config_entry.data["host"])
        current = self.hass.data[hue.DOMAIN][sensor_key]

        # Physical Hue motion sensors present as three sensors in the API: a
        # presence sensor, a temperature sensor, and a light level sensor. Of
        # these, only the presence sensor is assigned the user-friendly name
        # that the user has given to the device. Each of these sensors is
        # linked by a common device_id, which is the first twenty-three
        # characters of the unique id (then followed by a hyphen and an ID
        # specific to the individual sensor).
        #
        # To set up neat values, and assign the sensor entities to the same
        # device, we first, iterate over all the sensors and find the Hue
        # presence sensors, then iterate over all the remaining sensors -
        # finding the remaining ones that may or may not be related to the
        # presence sensors.
        for item_id in api:
            if api[item_id].type != TYPE_ZLL_PRESENCE:
                continue

            primary_sensor_devices[_device_id(api[item_id])] = api[item_id]

        # Iterate again now we have all the presence sensors, and add the
        # related sensors with nice names where appropriate.
        for item_id in api:
            existing = current.get(api[item_id].uniqueid)
            if existing is not None:
                self.hass.async_create_task(existing.async_maybe_update_ha_state())
                continue

            primary_sensor = None
            sensor_config = self.sensor_config_map.get(api[item_id].type)
            if sensor_config is None:
                continue

            base_name = api[item_id].name
            primary_sensor = primary_sensor_devices.get(_device_id(api[item_id]))
            if primary_sensor is not None:
                base_name = primary_sensor.name
            name = sensor_config["name_format"].format(base_name)

            current[api[item_id].uniqueid] = sensor_config["class"](
                api[item_id], name, self.bridge, primary_sensor=primary_sensor
            )
            if sensor_config["binary"]:
                new_binary_sensors.append(current[api[item_id].uniqueid])
            else:
                new_sensors.append(current[api[item_id].uniqueid])

        await remove_devices(
            self.hass,
            self.config_entry,
            [value.uniqueid for value in api.values()],
            current,
        )

        async_add_sensor_entities = self._component_add_entities.get(False)
        async_add_binary_entities = self._component_add_entities.get(True)
        if new_sensors and async_add_sensor_entities:
            async_add_sensor_entities(new_sensors)
        if new_binary_sensors and async_add_binary_entities:
            async_add_binary_entities(new_binary_sensors)


class GenericHueSensor:
    """Representation of a Hue sensor."""

    should_poll = False

    def __init__(self, sensor, name, bridge, primary_sensor=None):
        """Initialize the sensor."""
        self.sensor = sensor
        self._name = name
        self._primary_sensor = primary_sensor
        self.bridge = bridge

    async def _async_update_ha_state(self, *args, **kwargs):
        raise NotImplementedError

    @property
    def primary_sensor(self):
        """Return the primary sensor entity of the physical device."""
        return self._primary_sensor or self.sensor

    @property
    def device_id(self):
        """Return the ID of the physical device this sensor is part of."""
        return self.unique_id[:23]

    @property
    def unique_id(self):
        """Return the ID of this Hue sensor."""
        return self.sensor.uniqueid

    @property
    def name(self):
        """Return a friendly name for the sensor."""
        return self._name

    @property
    def available(self):
        """Return if sensor is available."""
        return (
            self.bridge.available
            and self.bridge.authorized
            and (self.bridge.allow_unreachable or self.sensor.config["reachable"])
        )

    @property
    def swupdatestate(self):
        """Return detail of available software updates for this device."""
        return self.primary_sensor.raw.get("swupdate", {}).get("state")

    async def async_maybe_update_ha_state(self):
        """Try to update Home Assistant with current state of entity.

        But if it's not been added to hass yet, then don't throw an error.
        """
        try:
            await self._async_update_ha_state()
        except (RuntimeError, NoEntitySpecifiedError):
            _LOGGER.debug("Hue sensor update requested before it has been added.")

    @property
    def device_info(self):
        """Return the device info.

        Links individual entities together in the hass device registry.
        """
        return {
            "identifiers": {(hue.DOMAIN, self.device_id)},
            "name": self.primary_sensor.name,
            "manufacturer": self.primary_sensor.manufacturername,
            "model": (self.primary_sensor.productname or self.primary_sensor.modelid),
            "sw_version": self.primary_sensor.swversion,
            "via_device": (hue.DOMAIN, self.bridge.api.config.bridgeid),
        }


class GenericZLLSensor(GenericHueSensor):
    """Representation of a Hue-brand, physical sensor."""

    @property
    def device_state_attributes(self):
        """Return the device state attributes."""
        return {"battery_level": self.sensor.battery}
