"""Support for RainMachine devices."""
import asyncio
from datetime import timedelta
import logging

from regenmaschine import Client
from regenmaschine.errors import RainMachineError
import voluptuous as vol

from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
    ATTR_ATTRIBUTION,
    CONF_IP_ADDRESS,
    CONF_PASSWORD,
    CONF_PORT,
    CONF_SCAN_INTERVAL,
    CONF_SSL,
)
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import verify_domain_control

from .const import (
    CONF_ZONE_RUN_TIME,
    DATA_CLIENT,
    DATA_PROGRAMS,
    DATA_PROVISION_SETTINGS,
    DATA_RESTRICTIONS_CURRENT,
    DATA_RESTRICTIONS_UNIVERSAL,
    DATA_ZONES,
    DATA_ZONES_DETAILS,
    DEFAULT_PORT,
    DEFAULT_SCAN_INTERVAL,
    DEFAULT_ZONE_RUN,
    DOMAIN,
    PROGRAM_UPDATE_TOPIC,
    SENSOR_UPDATE_TOPIC,
    ZONE_UPDATE_TOPIC,
)

_LOGGER = logging.getLogger(__name__)

CONF_CONTROLLERS = "controllers"
CONF_PROGRAM_ID = "program_id"
CONF_SECONDS = "seconds"
CONF_ZONE_ID = "zone_id"

DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC"
DEFAULT_ICON = "mdi:water"
DEFAULT_SSL = True

SERVICE_ALTER_PROGRAM = vol.Schema({vol.Required(CONF_PROGRAM_ID): cv.positive_int})

SERVICE_ALTER_ZONE = vol.Schema({vol.Required(CONF_ZONE_ID): cv.positive_int})

SERVICE_PAUSE_WATERING = vol.Schema({vol.Required(CONF_SECONDS): cv.positive_int})

SERVICE_START_PROGRAM_SCHEMA = vol.Schema(
    {vol.Required(CONF_PROGRAM_ID): cv.positive_int}
)

SERVICE_START_ZONE_SCHEMA = vol.Schema(
    {
        vol.Required(CONF_ZONE_ID): cv.positive_int,
        vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int,
    }
)

SERVICE_STOP_PROGRAM_SCHEMA = vol.Schema(
    {vol.Required(CONF_PROGRAM_ID): cv.positive_int}
)

SERVICE_STOP_ZONE_SCHEMA = vol.Schema({vol.Required(CONF_ZONE_ID): cv.positive_int})

CONTROLLER_SCHEMA = vol.Schema(
    {
        vol.Required(CONF_IP_ADDRESS): cv.string,
        vol.Required(CONF_PASSWORD): cv.string,
        vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
        vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
        vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All(
            cv.time_period, lambda value: value.total_seconds()
        ),
        vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int,
    }
)

CONFIG_SCHEMA = vol.Schema(
    {
        DOMAIN: vol.Schema(
            {
                vol.Required(CONF_CONTROLLERS): vol.All(
                    cv.ensure_list, [CONTROLLER_SCHEMA]
                )
            }
        )
    },
    extra=vol.ALLOW_EXTRA,
)


async def async_setup(hass, config):
    """Set up the RainMachine component."""
    hass.data[DOMAIN] = {}
    hass.data[DOMAIN][DATA_CLIENT] = {}

    if DOMAIN not in config:
        return True

    conf = config[DOMAIN]

    for controller in conf[CONF_CONTROLLERS]:
        hass.async_create_task(
            hass.config_entries.flow.async_init(
                DOMAIN, context={"source": SOURCE_IMPORT}, data=controller
            )
        )

    return True


async def async_setup_entry(hass, config_entry):
    """Set up RainMachine as config entry."""
    if not config_entry.unique_id:
        hass.config_entries.async_update_entry(
            config_entry, unique_id=config_entry.data[CONF_IP_ADDRESS]
        )

    _verify_domain_control = verify_domain_control(hass, DOMAIN)

    websession = aiohttp_client.async_get_clientsession(hass)
    client = Client(websession)

    try:
        await client.load_local(
            config_entry.data[CONF_IP_ADDRESS],
            config_entry.data[CONF_PASSWORD],
            port=config_entry.data[CONF_PORT],
            ssl=config_entry.data.get(CONF_SSL, DEFAULT_SSL),
        )
    except RainMachineError as err:
        _LOGGER.error("An error occurred: %s", err)
        raise ConfigEntryNotReady
    else:
        # regenmaschine can load multiple controllers at once, but we only grab the one
        # we loaded above:
        controller = next(iter(client.controllers.values()))

        rainmachine = RainMachine(
            hass,
            controller,
            config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN),
            config_entry.data.get(
                CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL.total_seconds()
            ),
        )

    # Update the data object, which at this point (prior to any sensors registering
    # "interest" in the API), will focus on grabbing the latest program and zone data:
    await rainmachine.async_update()
    hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = rainmachine

    for component in ("binary_sensor", "sensor", "switch"):
        hass.async_create_task(
            hass.config_entries.async_forward_entry_setup(config_entry, component)
        )

    @_verify_domain_control
    async def disable_program(call):
        """Disable a program."""
        await rainmachine.controller.programs.disable(call.data[CONF_PROGRAM_ID])
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def disable_zone(call):
        """Disable a zone."""
        await rainmachine.controller.zones.disable(call.data[CONF_ZONE_ID])
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def enable_program(call):
        """Enable a program."""
        await rainmachine.controller.programs.enable(call.data[CONF_PROGRAM_ID])
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def enable_zone(call):
        """Enable a zone."""
        await rainmachine.controller.zones.enable(call.data[CONF_ZONE_ID])
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def pause_watering(call):
        """Pause watering for a set number of seconds."""
        await rainmachine.controller.watering.pause_all(call.data[CONF_SECONDS])
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def start_program(call):
        """Start a particular program."""
        await rainmachine.controller.programs.start(call.data[CONF_PROGRAM_ID])
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def start_zone(call):
        """Start a particular zone for a certain amount of time."""
        await rainmachine.controller.zones.start(
            call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME]
        )
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def stop_all(call):
        """Stop all watering."""
        await rainmachine.controller.watering.stop_all()
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def stop_program(call):
        """Stop a program."""
        await rainmachine.controller.programs.stop(call.data[CONF_PROGRAM_ID])
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def stop_zone(call):
        """Stop a zone."""
        await rainmachine.controller.zones.stop(call.data[CONF_ZONE_ID])
        await rainmachine.async_update_programs_and_zones()

    @_verify_domain_control
    async def unpause_watering(call):
        """Unpause watering."""
        await rainmachine.controller.watering.unpause_all()
        await rainmachine.async_update_programs_and_zones()

    for service, method, schema in [
        ("disable_program", disable_program, SERVICE_ALTER_PROGRAM),
        ("disable_zone", disable_zone, SERVICE_ALTER_ZONE),
        ("enable_program", enable_program, SERVICE_ALTER_PROGRAM),
        ("enable_zone", enable_zone, SERVICE_ALTER_ZONE),
        ("pause_watering", pause_watering, SERVICE_PAUSE_WATERING),
        ("start_program", start_program, SERVICE_START_PROGRAM_SCHEMA),
        ("start_zone", start_zone, SERVICE_START_ZONE_SCHEMA),
        ("stop_all", stop_all, {}),
        ("stop_program", stop_program, SERVICE_STOP_PROGRAM_SCHEMA),
        ("stop_zone", stop_zone, SERVICE_STOP_ZONE_SCHEMA),
        ("unpause_watering", unpause_watering, {}),
    ]:
        hass.services.async_register(DOMAIN, service, method, schema=schema)

    return True


async def async_unload_entry(hass, config_entry):
    """Unload an OpenUV config entry."""
    hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)

    tasks = [
        hass.config_entries.async_forward_entry_unload(config_entry, component)
        for component in ("binary_sensor", "sensor", "switch")
    ]

    await asyncio.gather(*tasks)

    return True


class RainMachine:
    """Define a generic RainMachine object."""

    def __init__(self, hass, controller, default_zone_runtime, scan_interval):
        """Initialize."""
        self._async_cancel_time_interval_listener = None
        self._scan_interval_seconds = scan_interval
        self.controller = controller
        self.data = {}
        self.default_zone_runtime = default_zone_runtime
        self.device_mac = controller.mac
        self.hass = hass

        self._api_category_count = {
            DATA_PROVISION_SETTINGS: 0,
            DATA_RESTRICTIONS_CURRENT: 0,
            DATA_RESTRICTIONS_UNIVERSAL: 0,
        }
        self._api_category_locks = {
            DATA_PROVISION_SETTINGS: asyncio.Lock(),
            DATA_RESTRICTIONS_CURRENT: asyncio.Lock(),
            DATA_RESTRICTIONS_UNIVERSAL: asyncio.Lock(),
        }

    async def _async_update_listener_action(self, now):
        """Define an async_track_time_interval action to update data."""
        await self.async_update()

    @callback
    def async_deregister_sensor_api_interest(self, api_category):
        """Decrement the number of entities with data needs from an API category."""
        # If this deregistration should leave us with no registration at all, remove the
        # time interval:
        if sum(self._api_category_count.values()) == 0:
            if self._async_cancel_time_interval_listener:
                self._async_cancel_time_interval_listener()
                self._async_cancel_time_interval_listener = None
            return

        self._api_category_count[api_category] -= 1

    async def async_fetch_from_api(self, api_category):
        """Execute the appropriate coroutine to fetch particular data from the API."""
        if api_category == DATA_PROGRAMS:
            data = await self.controller.programs.all(include_inactive=True)
        elif api_category == DATA_PROVISION_SETTINGS:
            data = await self.controller.provisioning.settings()
        elif api_category == DATA_RESTRICTIONS_CURRENT:
            data = await self.controller.restrictions.current()
        elif api_category == DATA_RESTRICTIONS_UNIVERSAL:
            data = await self.controller.restrictions.universal()
        elif api_category == DATA_ZONES:
            data = await self.controller.zones.all(include_inactive=True)
        elif api_category == DATA_ZONES_DETAILS:
            # This API call needs to be separate from the DATA_ZONES one above because,
            # maddeningly, the DATA_ZONES_DETAILS API call doesn't include the current
            # state of the zone:
            data = await self.controller.zones.all(details=True, include_inactive=True)

        self.data[api_category] = data

    async def async_register_sensor_api_interest(self, api_category):
        """Increment the number of entities with data needs from an API category."""
        # If this is the first registration we have, start a time interval:
        if not self._async_cancel_time_interval_listener:
            self._async_cancel_time_interval_listener = async_track_time_interval(
                self.hass,
                self._async_update_listener_action,
                timedelta(seconds=self._scan_interval_seconds),
            )

        self._api_category_count[api_category] += 1

        # If a sensor registers interest in a particular API call and the data doesn't
        # exist for it yet, make the API call and grab the data:
        async with self._api_category_locks[api_category]:
            if api_category not in self.data:
                await self.async_fetch_from_api(api_category)

    async def async_update(self):
        """Update all RainMachine data."""
        tasks = [self.async_update_programs_and_zones(), self.async_update_sensors()]
        await asyncio.gather(*tasks)

    async def async_update_sensors(self):
        """Update sensor/binary sensor data."""
        _LOGGER.debug("Updating sensor data for RainMachine")

        # Fetch an API category if there is at least one interested entity:
        tasks = {}
        for category, count in self._api_category_count.items():
            if count == 0:
                continue
            tasks[category] = self.async_fetch_from_api(category)

        results = await asyncio.gather(*tasks.values(), return_exceptions=True)
        for api_category, result in zip(tasks, results):
            if isinstance(result, RainMachineError):
                _LOGGER.error(
                    "There was an error while updating %s: %s", api_category, result
                )
                continue

        async_dispatcher_send(self.hass, SENSOR_UPDATE_TOPIC)

    async def async_update_programs_and_zones(self):
        """Update program and zone data.

        Program and zone updates always go together because of how linked they are:
        programs affect zones and certain combinations of zones affect programs.

        Note that this call does not take into account interested entities when making
        the API calls; we make the reasonable assumption that switches will always be
        enabled.
        """
        _LOGGER.debug("Updating program and zone data for RainMachine")

        tasks = {
            DATA_PROGRAMS: self.async_fetch_from_api(DATA_PROGRAMS),
            DATA_ZONES: self.async_fetch_from_api(DATA_ZONES),
            DATA_ZONES_DETAILS: self.async_fetch_from_api(DATA_ZONES_DETAILS),
        }

        results = await asyncio.gather(*tasks.values(), return_exceptions=True)
        for api_category, result in zip(tasks, results):
            if isinstance(result, RainMachineError):
                _LOGGER.error(
                    "There was an error while updating %s: %s", api_category, result
                )

        async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC)
        async_dispatcher_send(self.hass, ZONE_UPDATE_TOPIC)


class RainMachineEntity(Entity):
    """Define a generic RainMachine entity."""

    def __init__(self, rainmachine):
        """Initialize."""
        self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
        self._device_class = None
        self._name = None
        self.rainmachine = rainmachine

    @property
    def device_class(self):
        """Return the device class."""
        return self._device_class

    @property
    def device_info(self):
        """Return device registry information for this entity."""
        return {
            "identifiers": {(DOMAIN, self.rainmachine.controller.mac)},
            "name": self.rainmachine.controller.name,
            "manufacturer": "RainMachine",
            "model": (
                f"Version {self.rainmachine.controller.hardware_version} "
                f"(API: {self.rainmachine.controller.api_version})"
            ),
            "sw_version": self.rainmachine.controller.software_version,
        }

    @property
    def device_state_attributes(self) -> dict:
        """Return the state attributes."""
        return self._attrs

    @property
    def name(self) -> str:
        """Return the name of the entity."""
        return self._name

    @property
    def should_poll(self):
        """Disable polling."""
        return False

    @callback
    def _update_state(self):
        """Update the state."""
        self.update_from_latest_data()
        self.async_write_ha_state()

    @callback
    def update_from_latest_data(self):
        """Update the entity."""
        raise NotImplementedError
