"""Contains the DeviceManager base class."""
import asyncio
from collections import OrderedDict

from typing import Callable, Tuple, List, Generator

from mpf.core.utility_functions import Util
from mpf.core.mpf_controller import MpfController

MYPY = False
if MYPY:   # pragma: no cover
    from mpf.core.device import Device  # pylint: disable-msg=cyclic-import,unused-import


class DeviceManager(MpfController):

    """Manages all the devices in MPF."""

    config_name = "device_manager"

    __slots__ = ["_monitorable_devices", "collections", "device_classes"]

    def __init__(self, machine):
        """Initialize device manager."""
        super().__init__(machine)

        self._monitorable_devices = {}

        self.collections = OrderedDict()
        self.device_classes = OrderedDict()  # collection_name: device_class

        # this has to happen after mode load
        self.machine.events.add_async_handler('init_phase_1',
                                              self._load_device_modules, priority=5)

        self.machine.events.add_handler('init_phase_2',
                                        self.create_machinewide_device_control_events,
                                        priority=2)

    def get_monitorable_devices(self):
        """Return all devices which are registered as monitorable."""
        return self._monitorable_devices

    def register_monitorable_device(self, device):
        """Register a monitorable device.

        Args:
        ----
            device: The device to register.
        """
        if device.collection not in self._monitorable_devices:
            self._monitorable_devices[device.collection] = {}
        self._monitorable_devices[device.collection][device.name] = device

    def notify_device_changes(self, device, notify, old, value):
        """Notify subscribers about changes in a registered device.

        Args:
        ----
            device: The device that changed.
            notify: Attribute name which changed.
            old: The old value.
            value: The new value.

        """
        self.machine.bcp.interface.notify_device_changes(device, notify, old, value)

    async def _load_device_modules(self, **kwargs):
        del kwargs
        # step 1: create devices in machine collection
        self.debug_log("Creating devices...")
        for device_type in self.machine.config['mpf']['device_modules']:
            device_cls = Util.string_to_class(device_type)      # type: Device

            collection_name, config = device_cls.get_config_info()

            self.device_classes[collection_name] = device_cls

            # create the collection
            collection = DeviceCollection(self.machine, collection_name,
                                          device_cls.config_section)

            self.collections[collection_name] = collection
            setattr(self.machine, collection_name, collection)

            # Get the config section for these devices
            config = self.machine.config.get(config, None)

            # create the devices
            if config:
                self.create_devices(collection_name, config)

            # create the default control events
            try:
                self._create_default_control_events(collection)
            except KeyError:
                pass

        self.machine.mode_controller.create_mode_devices()

        # step 2: load config and validate devices
        self.load_devices_config(validate=True)
        await self.machine.mode_controller.load_mode_devices()

        # step 3: initialise devices (mode devices will be initialised when mode is started)
        await self.initialize_devices()

    def stop_devices(self):
        """Stop all devices in the machine."""
        for device_type in self.machine.config['mpf']['device_modules']:
            device_cls = Util.string_to_class(device_type)
            collection_name, _ = device_cls.get_config_info()
            if not hasattr(self.machine, collection_name):
                continue
            for device in getattr(self.machine, collection_name).values():
                if hasattr(device, "stop_device"):
                    device.stop_device()

    def create_devices(self, collection_name, config):
        """Create devices for a collection."""
        cls = self.device_classes[collection_name]

        collection = getattr(self.machine, collection_name)

        # if this device class has a device_class_init classmethod, run it now
        if hasattr(cls, 'device_class_init'):
            # don't want to use try here in case the called method has an error
            cls.device_class_init(self.machine)

        # create the devices
        for device_name in config:

            if not config[device_name] and not cls.allow_empty_configs:
                self.raise_config_error("Device {}:'{}' has an empty config.".format(collection_name, device_name), 2,
                                        context=collection_name + "." + device_name)

            elif not isinstance(config[device_name], dict):
                self.raise_config_error("Device {}:'{}' does not have a valid config. Expected a dictionary.".format(
                    collection_name, device_name), 3, context=collection_name + "." + device_name)

            collection[device_name] = cls(self.machine, device_name)

    def load_devices_config(self, validate=True):
        """Load all devices."""
        if validate:
            for device_type in self.machine.config['mpf']['device_modules']:

                device_cls = Util.string_to_class(device_type)

                collection_name, config_name = device_cls.get_config_info()

                if config_name not in self.machine.config:
                    continue

                # Get the config section for these devices
                collection = getattr(self.machine, collection_name)
                config = self.machine.config[config_name]
                if not config:
                    self.machine.config[config_name] = config = {}
                if not isinstance(config, dict):
                    self.raise_config_error("Format of collection {} is invalid.".format(collection_name), 1)

                # validate config
                for device_name in config:
                    config[device_name] = collection[device_name].prepare_config(config[device_name], False)
                    config[device_name] = collection[device_name].validate_and_parse_config(config[device_name], False)

        for device_type in self.machine.config['mpf']['device_modules']:

            device_cls = Util.string_to_class(device_type)

            collection_name, config_name = device_cls.get_config_info()

            if config_name not in self.machine.config:
                continue

            # Get the config section for these devices
            collection = getattr(self.machine, collection_name)
            config = self.machine.config[config_name]

            # load config
            for device_name in config:
                collection[device_name].load_config(config[device_name])

    async def initialize_devices(self):
        """Initialise devices."""
        futures = []
        for device_type in self.machine.config['mpf']['device_modules']:

            device_cls = Util.string_to_class(device_type)

            collection_name, config_name = device_cls.get_config_info()

            if config_name not in self.machine.config:
                continue

            # Get the config section for these devices
            collection = getattr(self.machine, collection_name)
            config = self.machine.config[config_name]

            # add machine wide
            for device_name in config:
                futures.append(collection[device_name].device_added_system_wide())

        await asyncio.wait(futures)

    # pylint: disable-msg=too-many-nested-blocks
    def get_device_control_events(self, config) -> Generator[Tuple[str, Callable, int, "Device"], None, None]:
        """Scan a config dictionary for control_events.

         Yields events, methods, delays, and devices for all the devices and
         control_events in that config.

        Args:
        ----
            config: An MPF config dictionary (either machine-wide or mode-
                specific).

        Returns a generator of 4-item tuples
        ------------------------------------
            * The event name
            * The callback method of the device
            * The delay in ms
            * The device object
        """
        config_spec = self.machine.config_validator.get_config_spec()
        for collection in self.collections:
            config_section = self.collections[collection].config_section
            if config_section in config:
                for device, settings in iter(config[config_section].items()):

                    control_events = [x for x in settings if
                                      x.endswith('_events') and x != "control_events"]
                    device_obj = self.collections[collection][device]

                    for control_event in control_events:
                        # get events from this device's config
                        if not isinstance(settings[control_event], dict):
                            if config_spec[config_section][control_event] == "ignore":
                                continue
                            raise AssertionError(
                                "Type of {}:{} should be event_handler|event_handler:ms| in config_spec".format(
                                    collection, control_event))
                        # try the new style first
                        try:
                            method = getattr(device_obj, "event_{}".format(control_event[:-7]))
                        except AttributeError:
                            raise AssertionError("Class {} needs to have method event_{} to handle {}".format(
                                device_obj, control_event[:-7], control_event
                            ))
                        for event, delay in settings[control_event].items():
                            yield (event,
                                   method,
                                   delay,
                                   device_obj)

    def create_machinewide_device_control_events(self, **kwargs):
        """Create machine wide control events."""
        del kwargs
        for event, method, delay, _ in (
                self.get_device_control_events(self.machine.config)):

            if delay:
                self.machine.events.add_handler(
                    event=event,
                    handler=self._control_event_handler,
                    callback=method,
                    ms_delay=delay,
                    delay_mgr=self.machine.delay)
            else:
                self.machine.events.add_handler(
                    event=event,
                    handler=method)

    def _control_event_handler(self, callback, ms_delay, delay_mgr=None, **kwargs):
        del kwargs

        self.debug_log("_control_event_handler: callback: %s,", callback)
        delay_mgr.add(ms=ms_delay, callback=callback)

    def _create_default_control_events(self, device_list):
        for device in device_list.values():

            event_prefix = device.class_label + '_' + device.name + '_'
            event_prefix2 = device.collection + '_'

            for method in (self.machine.config['mpf']['device_events']
                           [device.config_section]):
                self.machine.events.add_handler(event=event_prefix + method,
                                                handler=getattr(device,
                                                                method))
                self.machine.events.add_handler(event=event_prefix2 + method,
                                                handler=getattr(device,
                                                                method))


class DeviceCollection(dict):

    """A collection of Devices.

    One instance of this class will be created for each different type of
    hardware device (such as coils, lights, switches, ball devices, etc.).
    """

    __slots__ = ["machine", "name", "config_section", "_tag_cache"]

    def __init__(self, machine, collection, config_section):
        """Initialise device collection."""
        super().__init__()

        self.machine = machine
        self.name = collection
        self.config_section = config_section
        self._tag_cache = dict()

    def __hash__(self):
        """Hash collection."""
        return hash((self.name, self.machine))

    def __delitem__(self, key):
        """Delete item for key."""
        # clear the tag cache
        self._tag_cache = dict()
        return super().__delitem__(key)

    def __getattr__(self, attr):
        """Return device by key.

        This method is DEPRECATED and will be removed soon.
        """
        # We used this to allow the programmer to access a hardware item like
        # self.coils.coilname

        try:
            return self[attr]
        except KeyError:
            raise KeyError('Error: No device exists with the name:', attr)

    def __iter__(self):
        """Iterate collection.

        This method is DEPRECATED and will be removed soon. Use .values() instead.
        """
        for item in self.values():
            yield item

    def items_tagged(self, tag) -> List["Device"]:
        """Return of list of device objects which have a certain tag.

        Args:
        ----
            tag: A string of the tag name which specifies what devices are
                returned.

        Returns a list of device objects. If no devices are found with that tag, it
        will return an empty list.
        """
        items_in_tag_cache = self._tag_cache.get(tag, None)
        if items_in_tag_cache is not None:
            return items_in_tag_cache

        items = [item for item in self.values() if hasattr(item, "tags") and tag in item.tags]
        self._tag_cache[tag] = items
        return items
