# Class for management payload threading.
#
# Copyright (C) 2019  Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# the GNU General Public License v.2, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY expressed or implied, including the implied warranties 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, write to the
# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
# source code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission of
# Red Hat, Inc.
#

import threading
from enum import IntEnum

from dasbus.error import DBusError
from pyanaconda.core.constants import THREAD_STORAGE, THREAD_PAYLOAD, THREAD_PAYLOAD_RESTART, \
    THREAD_WAIT_FOR_CONNECTING_NM, THREAD_SUBSCRIPTION, PAYLOAD_TYPE_DNF, \
    THREAD_STORAGE_WATCHER, THREAD_EXECUTE_STORAGE
from pyanaconda.core.i18n import _, N_
from pyanaconda.threading import threadMgr, AnacondaThread
from pyanaconda.errors import errorHandler, ERROR_RAISE
from pyanaconda.anaconda_loggers import get_module_logger

log = get_module_logger(__name__)


__all__ = ["payloadMgr", "PayloadState"]


class PayloadState(IntEnum):
    """Enum for payload state after payload was restarted."""
    STARTED = 0
    WAITING_STORAGE = 1
    WAITING_NETWORK = 2
    VERIFYING_AVAILABILITY = 3
    DOWNLOADING_PKG_METADATA = 4
    DOWNLOADING_GROUP_METADATA = 5
    FINISHED = 6

    # Error
    ERROR = -1


class PayloadManager(object):
    """Framework for starting and watching the payload thread.

    This class defines several states (PayloadState enum), and events can
    be triggered upon reaching a state. Depending on whether a state has
    already been reached when a listener is added, the event code may be
    run in either the calling thread or the payload thread. The event code
    will block the payload thread regardless, so try not to run anything
    that takes a long time.

    All states except ERROR are expected to happen linearly, and adding
    a listener for a state that has already been reached or passed will
    immediately trigger that listener. For example, if the payload thread is
    currently in DOWNLOADING_GROUP_METADATA, adding a listener for
    WAITING_NETWORK will immediately run the code being added
    for WAITING_NETWORK.

    The payload thread data should be accessed using the payloadMgr object,
    and the running thread can be accessed using threadMgr with the
    THREAD_PAYLOAD constant, if you need to wait for it or something. The
    thread should be started using payloadMgr.restart_thread.
    """
    # Error strings
    ERROR_SETUP = N_("Failed to set up installation source")
    ERROR_MD = N_("Error downloading package metadata")

    def __init__(self):
        self._event_lock = threading.Lock()
        self._event_listeners = {}
        self._thread_state = PayloadState.STARTED
        self._error = None

        # Initialize a list for each event state
        for _name, value in PayloadState.__members__.items():  # pylint: disable=no-member
            self._event_listeners[PayloadState(value)] = []

    @property
    def error(self):
        return _(self._error)

    def add_listener(self, event_id, func):
        """Add a listener for an event.

        :param int event_id: The event to listen for, one of the EVENT_* constants
        :param function func: An object to call when the event is reached
        """

        # Check that the event_id is valid
        assert isinstance(event_id, PayloadState)

        # Add the listener inside the lock in case we need to run immediately,
        # to make sure the listener isn't triggered twice
        with self._event_lock:
            self._event_listeners[event_id].append(func)

            # If an error event was requested, run it if currently in an error state
            if event_id == PayloadState.ERROR:
                if event_id == self._thread_state:
                    func()
            # Otherwise, run if the requested event has already occurred
            elif event_id <= self._thread_state:
                func()

    def restart_thread(self, payload, fallback=False, try_media=True, only_on_change=False):
        """Start or restart the payload thread.

        This method starts a new thread to restart the payload thread, so
        this method's return is not blocked by waiting on the previous payload
        thread. If there is already a payload thread restart pending, this method
        has no effect.

        :param payload.Payload payload: The payload instance
        :param bool fallback: Whether to fall back to the default repo in case of error
        :param bool try_media: Whether to check for valid mounted media
        :param bool only_on_change: Restart thread only if existing repositories changed.
            This won't restart thread even when a new repository was added!!
        """
        log.debug("Restarting payload thread")

        # If a restart thread is already running, don't start a new one
        if threadMgr.get(THREAD_PAYLOAD_RESTART):
            return

        # Launch a new thread so that this method can return immediately
        threadMgr.add(AnacondaThread(
            name=THREAD_PAYLOAD_RESTART,
            target=self._restart_thread,
            args=(payload, fallback, try_media, only_on_change)
        ))

    @property
    def running(self):
        """Is the payload thread running right now?"""
        return threadMgr.exists(THREAD_PAYLOAD_RESTART) or threadMgr.exists(THREAD_PAYLOAD)

    def _restart_thread(self, payload, fallback, try_media, only_on_change):
        # Wait for the old thread to finish
        threadMgr.wait(THREAD_PAYLOAD)

        # Start a new payload thread
        threadMgr.add(AnacondaThread(
            name=THREAD_PAYLOAD,
            target=self._run_thread,
            args=(payload, fallback, try_media, only_on_change)
        ))

    def _set_state(self, event_id):
        # Update the current state
        log.debug("Updating payload thread state: %s", event_id.name)
        with self._event_lock:
            # Update the state within the lock to avoid a race with listeners
            # currently being added
            self._thread_state = event_id

            # Run any listeners for the new state
            for func in self._event_listeners[event_id]:
                func()

    def _run_thread(self, payload, fallback, try_media, only_on_change):
        # This is the thread entry
        # Set the initial state
        self._error = None
        self._set_state(PayloadState.STARTED)

        # Wait for storage
        self._set_state(PayloadState.WAITING_STORAGE)
        threadMgr.wait(THREAD_STORAGE)
        threadMgr.wait(THREAD_STORAGE_WATCHER)
        threadMgr.wait(THREAD_EXECUTE_STORAGE)

        # Wait for network
        self._set_state(PayloadState.WAITING_NETWORK)
        # FIXME: condition for cases where we don't want network
        # (set and use payload.needs_network ?)
        threadMgr.wait(THREAD_WAIT_FOR_CONNECTING_NM)

        # Wait for subscription
        threadMgr.wait(THREAD_SUBSCRIPTION)

        # Non-package payloads do everything in the setup method.
        # There is no UI support that could handle the error state,
        # so we need to handle or raise the error directly.
        try:
            payload.setup()
        except DBusError as e:
            # Handle an error.
            if errorHandler.cb(e) == ERROR_RAISE:
                raise

        # If this is a non-package Payload, we're done
        if payload.type != PAYLOAD_TYPE_DNF:
            self._set_state(PayloadState.FINISHED)
            return

        # Test if any repository changed from the last update
        if only_on_change:
            log.debug("Testing repositories availability")
            self._set_state(PayloadState.VERIFYING_AVAILABILITY)
            if payload.dnf_manager.verify_repomd_hashes():
                log.debug("Payload isn't restarted, repositories are still available.")
                self._set_state(PayloadState.FINISHED)
                return

        # Keep setting up package-based repositories
        # Download package metadata
        self._set_state(PayloadState.DOWNLOADING_PKG_METADATA)

        # FIXME: This import is a temporary workaround. Use a DBus error instead.
        from pyanaconda.modules.payloads.payload.dnf.dnf_manager import DNFManagerError

        try:
            payload.update_base_repo(fallback=fallback, try_media=try_media)
        except (OSError, DBusError, DNFManagerError) as e:
            log.error("Payload error: %s", e)
            self._error = self.ERROR_SETUP
            self._set_state(PayloadState.ERROR)
            payload.unsetup()
            return

        # Gather the group data
        self._set_state(PayloadState.DOWNLOADING_GROUP_METADATA)
        payload.dnf_manager.load_packages_metadata()

        # Check if that failed
        if not payload.is_ready():
            log.error("No base repo configured")
            self._error = self.ERROR_MD
            self._set_state(PayloadState.ERROR)
            payload.unsetup()
            return

        # run payload specific post configuration tasks
        payload.dnf_manager.load_repomd_hashes()

        self._set_state(PayloadState.FINISHED)


# Initialize the PayloadManager instance
payloadMgr = PayloadManager()
