#!/usr/bin/env python
# Safe Eyes is a utility to remind you to take break frequently
# to protect your eyes from eye strain.

# Copyright (C) 2017  Gobinath

# 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 3 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/>.
"""PluginManager loads all enabled plugins and call their lifecycle methods.

A plugin must have the following directory structure:
<plugin-id>
    |- config.json
    |- plugin.py
    |- icon.png (Optional)

The plugin.py can have following lifecycle methods but all are optional:
 - init(context, safeeyes_config, plugin_config)
    Initialize the plugin. Will be called after loading and after every changes in
    configuration
 - on_start()
    Executes when Safe Eyes is enabled
 - on_stop()
    Executes when Safe Eyes is disabled
 - on_exit()
    Executes before Safe Eyes exits
 - on_pre_break(break_obj)
    Executes at the start of the prepare time for a break
 - on_start_break(break_obj)
    Executes when a break starts
 - on_stop_break()
    Executes when a break stops
 - on_countdown(countdown, seconds)
    Executes every second throughout a break
 - update_next_break(break_obj, break_time)
    Executes when the next break changes
 - enable()
    Executes once the plugin.py is loaded as a module
 - disable()
    Executes if the plugin is disabled at the runtime by the user

The plugin.py can additionally have the following methods:
 - get_widget_title(break_obj)
    Returns title of this plugin's widget on the break screen
    If this is used, it must also use get_widget_content to work correctly
 - get_widget_content(break_obj)
    Returns content of this plugin's widget on the break screen
    If this is used, it must also use get_widget_title to work correctly
 - get_tray_action(break_obj) -> TrayAction | list[TrayAction]
    Display button(s) on the break screen's tray that triggers an action

This method is unused:
 - description()
    If a custom description has to be displayed, use this function
"""

import importlib
import logging
import os
import sys
import typing

from safeeyes import utility
from safeeyes.configuration import Config
from safeeyes.context import Context
from safeeyes.model import (
    Break,
    PluginDependency,
    RequiredPluginException,
    TrayAction,
)

sys.path.append(os.path.abspath(utility.SYSTEM_PLUGINS_DIR))
sys.path.append(os.path.abspath(utility.USER_PLUGINS_DIR))

HORIZONTAL_LINE_LENGTH = 64


class PluginManager:
    """Imports the Safe Eyes plugins and calls the methods defined in those plugins."""

    __plugins: dict[str, "LoadedPlugin"]
    last_break: typing.Optional[Break]

    def __init__(self) -> None:
        logging.info("Load all the plugins")
        self.__plugins = {}
        self.last_break = None
        self.horizontal_line = "─" * HORIZONTAL_LINE_LENGTH

    def init(self, context: Context, config: Config) -> None:
        """Initialize all the plugins with init(context, safe_eyes_config,
        plugin_config) function.
        """
        # Load the plugins
        for plugin in config.get("plugins"):
            try:
                loaded_plugin = LoadedPlugin(plugin)
                self.__plugins[loaded_plugin.id] = loaded_plugin
            except RequiredPluginException as e:
                raise e
            except BaseException as e:
                traceback_wanted = (
                    logging.getLogger().getEffectiveLevel() == logging.DEBUG
                )
                if traceback_wanted:
                    import traceback

                    traceback.print_exc()
                logging.error("Error in loading the plugin %s: %s", plugin["id"], e)
                continue
        # Initialize the plugins
        for plugin in self.__plugins.values():
            plugin.init_plugin(context, config)

    def reload(self, context: Context, config: Config) -> None:
        """Reinitialize all the plugins with updated config."""
        plugin_ids: set[str] = set()
        # Load the plugins
        for plugin in config.get("plugins"):
            plugin_id = plugin["id"]
            plugin_ids.add(plugin_id)
            if plugin_id in self.__plugins:
                self.__plugins[plugin_id].reload_config(plugin)
            else:
                try:
                    loaded_plugin = LoadedPlugin(plugin)
                    self.__plugins[loaded_plugin.id] = loaded_plugin
                except BaseException as e:
                    traceback_wanted = (
                        logging.getLogger().getEffectiveLevel() == logging.DEBUG
                    )
                    if traceback_wanted:
                        import traceback

                        traceback.print_exc()
                    logging.error("Error in loading the plugin %s: %s", plugin["id"], e)
                    continue

        removed_plugins = set(self.__plugins.keys()).difference(plugin_ids)
        for plugin_id in removed_plugins:
            self.__plugins[plugin_id].disable()

        # Initialize the plugins
        for plugin in self.__plugins.values():
            plugin.init_plugin(context, config)

    def needs_retry(self) -> bool:
        return self.get_retryable_error() is not None

    def get_retryable_error(self) -> typing.Optional[RequiredPluginException]:
        for plugin in self.__plugins.values():
            if plugin.required_plugin and plugin.errored and plugin.enabled:
                if (
                    isinstance(plugin.last_error, PluginDependency)
                    and plugin.last_error.retryable
                ):
                    return RequiredPluginException(
                        plugin.id, plugin.get_name(), plugin.last_error
                    )

        return None

    def retry_errored_plugins(self) -> None:
        for plugin in self.__plugins.values():
            if plugin.required_plugin and plugin.errored and plugin.enabled:
                if (
                    isinstance(plugin.last_error, PluginDependency)
                    and plugin.last_error.retryable
                ):
                    plugin.reload_errored()

    def start(self) -> None:
        """Execute the on_start() function of plugins."""
        for plugin in self.__plugins.values():
            plugin.call_plugin_method("on_start")

    def stop(self) -> None:
        """Execute the on_stop() function of plugins."""
        for plugin in self.__plugins.values():
            plugin.call_plugin_method("on_stop")

    def exit(self) -> None:
        """Execute the on_exit() function of plugins."""
        for plugin in self.__plugins.values():
            plugin.call_plugin_method("on_exit")

    def pre_break(self, break_obj) -> bool:
        """Execute the on_pre_break(break_obj) function of plugins."""
        for plugin in self.__plugins.values():
            if plugin.call_plugin_method_break_obj("on_pre_break", 1, break_obj):
                return False
        return True

    def start_break(self, break_obj) -> bool:
        """Execute the start_break(break_obj) function of plugins."""
        self.last_break = break_obj
        for plugin in self.__plugins.values():
            if plugin.call_plugin_method_break_obj("on_start_break", 1, break_obj):
                return False

        return True

    def stop_break(self) -> None:
        """Execute the stop_break() function of plugins."""
        for plugin in self.__plugins.values():
            plugin.call_plugin_method("on_stop_break")

    def countdown(self, countdown, seconds) -> None:
        """Execute the on_countdown(countdown, seconds) function of plugins."""
        for plugin in self.__plugins.values():
            plugin.call_plugin_method("on_countdown", 2, countdown, seconds)

    def update_next_break(self, break_obj, break_time) -> None:
        """Execute the update_next_break(break_time) function of plugins."""
        for plugin in self.__plugins.values():
            plugin.call_plugin_method_break_obj(
                "update_next_break", 2, break_obj, break_time
            )

    def get_break_screen_widgets(self, break_obj) -> str:
        """Return the HTML widget generated by the plugins.

        The widget is generated by calling the get_widget_title and
        get_widget_content functions of plugins.
        """
        widget = ""
        for plugin in self.__plugins.values():
            try:
                title = plugin.call_plugin_method_break_obj(
                    "get_widget_title", 1, break_obj
                )
                if title is None or not isinstance(title, str) or title == "":
                    continue
                content = plugin.call_plugin_method_break_obj(
                    "get_widget_content", 1, break_obj
                )
                if content is None or not isinstance(content, str) or content == "":
                    continue
                title = title.upper().strip()
                if title == "":
                    continue
                widget += "<b>{}</b>\n{}\n{}\n\n\n".format(
                    title, self.horizontal_line, content
                )
            except BaseException:
                continue
        return widget.strip()

    def get_break_screen_tray_actions(self, break_obj: Break) -> list[TrayAction]:
        """Return Tray Actions."""
        actions = []
        for plugin in self.__plugins.values():
            action = plugin.call_plugin_method_break_obj(
                "get_tray_action", 1, break_obj
            )
            if isinstance(action, TrayAction):
                actions.append(action)
            elif isinstance(action, list):
                for a in action:
                    if isinstance(a, TrayAction):
                        actions.append(a)

        return actions


class LoadedPlugin:
    # state of the plugin
    enabled: bool = False
    break_override_allowed: bool = False
    errored: bool = False
    required_plugin: bool = False

    # misc data
    # FIXME: rename to plugin_config to plugin_json? plugin_config and config are easy
    # to confuse
    config: dict
    plugin_config: dict
    plugin_dir: str
    module: typing.Optional[typing.Any] = None
    last_error: typing.Optional[typing.Union[str, PluginDependency]] = None
    id: str

    def __init__(self, plugin: dict) -> None:
        (plugin_config, plugin_dir) = self._load_config_json(plugin["id"])

        self.id = plugin["id"]
        self.plugin_config = plugin_config
        self.plugin_dir = plugin_dir
        self.enabled = plugin["enabled"]
        self.break_override_allowed = plugin_config.get("break_override_allowed", False)
        self.required_plugin = plugin_config.get("required_plugin", False)

        self.config = dict(plugin.get("settings", {}))
        self.config["path"] = os.path.join(plugin_dir, plugin["id"])

        if self.enabled or self.break_override_allowed:
            plugin_path = os.path.join(plugin_dir, self.id)
            message = utility.check_plugin_dependencies(
                plugin["id"], plugin_config, plugin.get("settings", {}), plugin_path
            )

            if message:
                self.errored = True
                self.last_error = message
                if self.required_plugin and not (
                    isinstance(message, PluginDependency) and message.retryable
                ):
                    raise RequiredPluginException(
                        plugin["id"], plugin_config["meta"]["name"], message
                    )
                return

            self._import_plugin()

    def reload_config(self, plugin: dict) -> None:
        if not plugin["enabled"]:
            self.disable()
            return

        if not self.enabled and plugin["enabled"]:
            self.enabled = True

        # Update the config
        self.config = dict(plugin.get("settings", {}))
        self.config["path"] = os.path.join(self.plugin_dir, plugin["id"])

        if self.enabled or self.break_override_allowed:
            plugin_path = os.path.join(self.plugin_dir, self.id)
            message = utility.check_plugin_dependencies(
                self.id, self.plugin_config, self.config, plugin_path
            )

            if message:
                self.errored = True
                self.last_error = message
            elif self.errored:
                self.errored = False
                self.last_error = None

            if not self.errored and self.module is None:
                # No longer errored, import the module now
                self._import_plugin()

    def disable(self) -> None:
        if self.enabled:
            self.enabled = False
            if (
                not self.errored
                and self.module is not None
                and utility.has_method(self.module, "disable")
            ):
                self.module.disable()
            logging.info("Successfully unloaded the plugin '%s'", self.id)

    def reload_errored(self) -> None:
        if not self.errored:
            return

        if self.enabled or self.break_override_allowed:
            plugin_path = os.path.join(self.plugin_dir, self.id)
            message = utility.check_plugin_dependencies(
                self.id, self.plugin_config, self.config, plugin_path
            )

            if message:
                self.errored = True
                self.last_error = message
            elif self.errored:
                self.errored = False
                self.last_error = None

            if not self.errored and self.module is None:
                # No longer errored, import the module now
                self._import_plugin()

    def get_name(self) -> str:
        return self.plugin_config["meta"]["name"]

    def _import_plugin(self) -> None:
        if self.errored:
            # do not try to import errored plugin
            return

        self.module = importlib.import_module((self.id + ".plugin"))
        logging.info("Successfully loaded %s", str(self.module))

        if utility.has_method(self.module, "enable"):
            self.module.enable()

    def _load_config_json(self, plugin_id: str) -> typing.Tuple[dict, str]:
        # Look for plugin.py
        if os.path.isfile(
            os.path.join(utility.SYSTEM_PLUGINS_DIR, plugin_id, "plugin.py")
        ):
            plugin_dir = utility.SYSTEM_PLUGINS_DIR
        elif os.path.isfile(
            os.path.join(utility.USER_PLUGINS_DIR, plugin_id, "plugin.py")
        ):
            plugin_dir = utility.USER_PLUGINS_DIR
        else:
            raise Exception("plugin.py not found for the plugin: %s", plugin_id)
        # Look for config.json
        plugin_path = os.path.join(plugin_dir, plugin_id)
        plugin_config_path = os.path.join(plugin_path, "config.json")
        if not os.path.isfile(plugin_config_path):
            raise Exception("config.json not found for the plugin: %s", plugin_id)
        plugin_config = utility.load_json(plugin_config_path)
        if plugin_config is None:
            raise Exception("config.json empty/invalid for the plugin: %s", plugin_id)

        return (plugin_config, plugin_dir)

    def init_plugin(self, context: Context, safeeyes_config: Config) -> None:
        if self.errored:
            return
        if self.break_override_allowed or self.enabled:
            if self.module is not None and utility.has_method(self.module, "init", 3):
                self.module.init(context, safeeyes_config, self.config)

    def call_plugin_method_break_obj(
        self, method_name: str, num_args, break_obj, *args, **kwargs
    ) -> typing.Any:
        if self.errored:
            return None

        enabled = False
        if self.break_override_allowed:
            enabled = break_obj.plugin_enabled(self.id, self.enabled)
        else:
            enabled = self.enabled

        if enabled:
            return self._call_plugin_method_internal(
                method_name, num_args, break_obj, *args, **kwargs
            )

        return None

    def call_plugin_method(
        self, method_name: str, num_args=0, *args, **kwargs
    ) -> typing.Any:
        if self.errored:
            return None

        if self.enabled:
            return self._call_plugin_method_internal(
                method_name, num_args, *args, **kwargs
            )

        return None

    def _call_plugin_method_internal(
        self, method_name: str, num_args=0, *args, **kwargs
    ) -> typing.Any:
        # FIXME: cache if method exists
        if utility.has_method(self.module, method_name, num_args):
            return getattr(self.module, method_name)(*args, **kwargs)
        return None
