#
# Support for object containers
#
# Copyright (C) 2019  Red Hat, Inc.  All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
from functools import partial
from queue import SimpleQueue

from pyanaconda.anaconda_loggers import get_module_logger
from dasbus.constants import DBUS_FLAG_NONE, DBUS_START_REPLY_SUCCESS
from pyanaconda.modules.boss.module_manager import ModuleObserver
from pyanaconda.modules.common.errors.module import UnavailableModuleError
from pyanaconda.modules.common.task import Task

log = get_module_logger(__name__)

__all__ = ["StartModulesTask"]


class StartModulesTask(Task):
    """A task for starting DBus modules.

    The timeout service_start_timeout from the Anaconda bus
    configuration file is applied by default when the DBus
    method StartServiceByName is called.
    """

    def __init__(self, message_bus, activatable, forbidden, optional):
        """Create a new task.

        Anaconda modules are specified by their full DBus name or a prefix
        of their DBus name that ends with '*'.

        :param message_bus: a message bus
        :param activatable: a list of modules that can be activated.
        :param forbidden: a list of modules that are are not allowed to run
        :param optional: a list of modules that are optional
        """
        super().__init__()
        self._message_bus = message_bus
        self._activatable = activatable
        self._forbidden = forbidden
        self._optional = optional
        self._module_observers = []
        self._callbacks = SimpleQueue()

    @property
    def name(self):
        """Name of the task."""
        return "Start the modules"

    def run(self):
        """Run the task.

        :return: a list of observers
        """
        # Collect the modules.
        self._module_observers = self._find_modules()

        # Asynchronously start the modules.
        self._start_modules(self._module_observers)

        # Process the callbacks of the asynchronous calls.
        self._process_callbacks(self._module_observers)

        return self._module_observers

    @staticmethod
    def _match_module(name, patterns):
        """Match a module with one of the specified patterns."""
        for pattern in patterns:
            # Match the name prefix.
            if pattern.endswith("*") and name.startswith(pattern[:-1]):
                return True

            # Match the full name.
            if name == pattern:
                return True

        return False

    def _find_modules(self):
        """Find modules to start."""
        modules = []

        dbus = self._message_bus.proxy
        names = dbus.ListActivatableNames()

        for service_name in names:
            # Only activatable modules can be started.
            if not self._match_module(service_name, self._activatable):
                continue

            # Forbidden modules are not allowed to run.
            if self._match_module(service_name, self._forbidden):
                log.debug(
                    "Skip %s. The module won't be started, because it's "
                    "marked as forbidden in the Anaconda configuration "
                    "files.", service_name
                )
                continue

            log.debug("Found %s.", service_name)
            modules.append(ModuleObserver(
                self._message_bus,
                service_name,
            ))

        return modules

    def _start_modules(self, module_observers):
        """Start the modules."""
        dbus = self._message_bus.proxy

        for observer in module_observers:
            log.debug("Starting %s.", observer)

            dbus.StartServiceByName(
                observer.service_name,
                DBUS_FLAG_NONE,
                callback=self._start_service_by_name_callback,
                callback_args=(observer,)
            )

    def _start_service_by_name_callback(self, call, observer):
        """Callback for the StartServiceByName method."""
        self._callbacks.put((observer, partial(self._start_service_by_name_handler, call)))

    def _start_service_by_name_handler(self, call, observer):
        """Handler for the StartServiceByName method."""
        try:
            returned = call()
        except Exception as error:  # pylint: disable=broad-except
            raise UnavailableModuleError(
                "Service {} has failed to start: {}".format(observer, error)
            ) from error

        if returned != DBUS_START_REPLY_SUCCESS:
            log.warning("Service %s is already running.", observer)
        else:
            log.debug("Service %s started successfully.", observer)

        # Connect the observer once the service is available.
        observer.service_available.connect(self._service_available_callback)
        observer.connect_once_available()
        return False

    def _service_available_callback(self, observer):
        """Callback for the service_available signal."""
        self._callbacks.put((observer, self._service_available_handler))

    def _service_available_handler(self, observer):
        """Handler for the service_available signal."""
        log.debug("%s is available.", observer)
        observer.proxy.Ping()
        return True

    def _process_callbacks(self, module_observers):
        """Process callbacks of the asynchronous calls.

        Process callbacks of the asynchronous calls until all modules
        are processed. A callback returns True if the module is processed,
        otherwise False.

        If a DBus call fails with an error, we raise an exception in the
        callback and immediately quit the task unless it comes from an
        add-on. A failure of an add-on module is not fatal, we just remove
        its observer from the list of available modules and continue.

        :param module_observers: a list of module observers
        """
        available = module_observers
        unprocessed = set(module_observers)

        while unprocessed:
            # Call the next scheduled callback.
            observer, callback = self._callbacks.get()

            try:
                is_available = callback(observer)

                # The module is not processed yet.
                if not is_available:
                    continue

            except UnavailableModuleError:
                # The failure of a required module is fatal.
                if not self._match_module(observer.service_name, self._optional):
                    raise

                # The failure of an optional module is not fatal. Remove
                # it from the list of available modules and continue.
                log.debug(
                    "Skip %s. The optional module has failed to start, "
                    "so it won't be available during the installation.",
                    observer.service_name
                )
                available.remove(observer)

            # The module is processed.
            unprocessed.discard(observer)
