# coding=utf-8
# Copyright 2008, Sean B. Palmer, inamidst.com
# Copyright © 2012, Elad Alfassa <elad@fedoraproject.org>
# Copyright 2012-2015, Elsie Powell, http://embolalia.com
# Copyright 2019, Florian Strzelecki <florian.strzelecki@gmail.com>
#
# Licensed under the Eiffel Forum License 2.

from __future__ import absolute_import, division, print_function, unicode_literals

from ast import literal_eval
from datetime import datetime
import inspect
import itertools
import logging
import re
import signal
import sys
import threading
import time

from sopel import irc, logger, plugins, tools
from sopel.db import SopelDB
import sopel.loader
from sopel.module import NOLIMIT
from sopel.plugins import jobs as plugin_jobs, rules as plugin_rules
from sopel.tools import deprecated, Identifier
import sopel.tools.jobs
from sopel.trigger import Trigger


__all__ = ['Sopel', 'SopelWrapper']

LOGGER = logging.getLogger(__name__)
QUIT_SIGNALS = [
    getattr(signal, name)
    for name in ['SIGUSR1', 'SIGTERM', 'SIGINT']
    if hasattr(signal, name)
]
RESTART_SIGNALS = [
    getattr(signal, name)
    for name in ['SIGUSR2', 'SIGILL']
    if hasattr(signal, name)
]
SIGNALS = QUIT_SIGNALS + RESTART_SIGNALS


if sys.version_info.major >= 3:
    unicode = str
    basestring = str
    py3 = True
else:
    py3 = False


class Sopel(irc.AbstractBot):
    def __init__(self, config, daemon=False):
        super(Sopel, self).__init__(config)
        self._daemon = daemon  # Used for iPython. TODO something saner here
        self.wantsrestart = False
        self._running_triggers = []
        self._running_triggers_lock = threading.Lock()
        self._plugins = {}
        self._rules_manager = plugin_rules.Manager()
        self._scheduler = plugin_jobs.Scheduler(self)

        self._times = {}
        """
        A dictionary mapping lowercased nicks to dictionaries which map
        function names to the time which they were last used by that nick.
        """

        self.server_capabilities = {}
        """A dict mapping supported IRCv3 capabilities to their options.

        For example, if the server specifies the capability ``sasl=EXTERNAL``,
        it will be here as ``{"sasl": "EXTERNAL"}``. Capabilities specified
        without any options will have ``None`` as the value.

        For servers that do not support IRCv3, this will be an empty set.
        """

        self.privileges = dict()
        """A dictionary of channels to their users and privilege levels.

        The value associated with each channel is a dictionary of
        :class:`sopel.tools.Identifier`\\s to a bitwise integer value,
        determined by combining the appropriate constants from
        :mod:`sopel.plugin`.

        .. deprecated:: 6.2.0
            Use :attr:`channels` instead. Will be removed in Sopel 8.
        """

        self.channels = tools.SopelIdentifierMemory()
        """A map of the channels that Sopel is in.

        The keys are :class:`sopel.tools.Identifier`\\s of the channel names,
        and map to :class:`sopel.tools.target.Channel` objects which contain
        the users in the channel and their permissions.
        """

        self.users = tools.SopelIdentifierMemory()
        """A map of the users that Sopel is aware of.

        The keys are :class:`sopel.tools.Identifier`\\s of the nicknames, and
        map to :class:`sopel.tools.target.User` instances. In order for Sopel
        to be aware of a user, it must share at least one mutual channel.
        """

        self.db = SopelDB(config)
        """The bot's database, as a :class:`sopel.db.SopelDB` instance."""

        self.memory = tools.SopelMemory()
        """
        A thread-safe dict for storage of runtime data to be shared between
        plugins. See :class:`sopel.tools.SopelMemory`.
        """

        self.shutdown_methods = []
        """List of methods to call on shutdown."""

    @property
    def rules(self):
        """Rules manager."""
        return self._rules_manager

    @property
    def scheduler(self):
        """Job Scheduler. See :func:`sopel.plugin.interval`."""
        return self._scheduler

    @property
    def command_groups(self):
        """A mapping of plugin names to lists of their commands.

        .. versionchanged:: 7.1
            This attribute is now generated on the fly from the registered list
            of commands and nickname commands.
        """
        # This was supposed to be deprecated, but the built-in help plugin needs it
        # TODO: create a new, better, doc interface to remove it
        plugin_commands = itertools.chain(
            self._rules_manager.get_all_commands(),
            self._rules_manager.get_all_nick_commands(),
        )
        result = {}

        for plugin, commands in plugin_commands:
            if plugin not in result:
                result[plugin] = list(sorted(commands.keys()))
            else:
                result[plugin].extend(commands.keys())
                result[plugin] = list(sorted(result[plugin]))

        return result

    @property
    def doc(self):
        """A dictionary of command names to their documentation.

        Each command is mapped to its docstring and any available examples, if
        declared in the plugin's code.

        .. versionchanged:: 3.2
            Use the first item in each callable's commands list as the key,
            instead of the function name as declared in the source code.

        .. versionchanged:: 7.1
            This attribute is now generated on the fly from the registered list
            of commands and nickname commands.
        """
        # TODO: create a new, better, doc interface to remove it
        plugin_commands = itertools.chain(
            self._rules_manager.get_all_commands(),
            self._rules_manager.get_all_nick_commands(),
        )
        commands = (
            (command, command.get_doc(), command.get_usages())
            for plugin, commands in plugin_commands
            for command in commands.values()
        )

        return dict(
            (name, (doc.splitlines(), [u['text'] for u in usages]))
            for command, doc, usages in commands
            for name in ((command.name,) + command.aliases)
        )

    @property
    def hostmask(self):
        """The current hostmask for the bot :class:`sopel.tools.target.User`.

        :return: the bot's current hostmask
        :rtype: str

        Bot must be connected and in at least one channel.
        """
        if not self.users or self.nick not in self.users:
            raise KeyError("'hostmask' not available: bot must be connected and in at least one channel.")

        return self.users.get(self.nick).hostmask

    def has_channel_privilege(self, channel, privilege):
        """Tell if the bot has a ``privilege`` level or above in a ``channel``.

        :param str channel: a channel the bot is in
        :param int privilege: privilege level to check
        :raise ValueError: when the channel is unknown

        This method checks the bot's privilege level in a channel, i.e. if it
        has this level or higher privileges::

            >>> bot.channels['#chan'].privileges[bot.nick] = plugin.OP
            >>> bot.has_channel_privilege('#chan', plugin.VOICE)
            True

        The ``channel`` argument can be either a :class:`str` or a
        :class:`sopel.tools.Identifier`, as long as Sopel joined said channel.
        If the channel is unknown, a :exc:`ValueError` will be raised.
        """
        if channel not in self.channels:
            raise ValueError('Unknown channel %s' % channel)

        return self.channels[channel].has_privilege(self.nick, privilege)

    # signal handlers

    def set_signal_handlers(self):
        """Set signal handlers for the bot.

        Before running the bot, this method can be called from the main thread
        to setup signals. If the bot is connected, upon receiving a signal it
        will send a ``QUIT`` message. Otherwise, it raises a
        :exc:`KeyboardInterrupt` error.

        .. note::

            Per the Python documentation of :func:`signal.signal`:

                When threads are enabled, this function can only be called from
                the main thread; attempting to call it from other threads will
                cause a :exc:`ValueError` exception to be raised.

        """
        for obj in SIGNALS:
            signal.signal(obj, self._signal_handler)

    def _signal_handler(self, sig, frame):
        if sig in QUIT_SIGNALS:
            if self.backend.is_connected():
                LOGGER.warning("Got quit signal, sending QUIT to server.")
                self.quit('Closing')
            else:
                self.hasquit = True  # mark the bot as "want to quit"
                LOGGER.warning("Got quit signal.")
                raise KeyboardInterrupt
        elif sig in RESTART_SIGNALS:
            if self.backend.is_connected():
                LOGGER.warning("Got restart signal, sending QUIT to server.")
                self.restart('Restarting')
            else:
                LOGGER.warning("Got restart signal.")
                self.wantsrestart = True  # mark the bot as "want to restart"
                self.hasquit = True  # mark the bot as "want to quit"
                raise KeyboardInterrupt

    # setup

    def setup(self):
        """Set up Sopel bot before it can run.

        The setup phase is in charge of:

        * setting up logging (configure Python's built-in :mod:`logging`)
        * setting up the bot's plugins (load, setup, and register)
        * starting the job scheduler
        """
        self.setup_logging()
        self.setup_plugins()
        self.post_setup()

    def setup_logging(self):
        """Set up logging based on config options."""
        logger.setup_logging(self.settings)
        base_level = self.settings.core.logging_level or 'INFO'
        base_format = self.settings.core.logging_format
        base_datefmt = self.settings.core.logging_datefmt

        # configure channel logging if required by configuration
        if self.settings.core.logging_channel:
            channel_level = self.settings.core.logging_channel_level or base_level
            channel_format = self.settings.core.logging_channel_format or base_format
            channel_datefmt = self.settings.core.logging_channel_datefmt or base_datefmt
            channel_params = {}
            if channel_format:
                channel_params['fmt'] = channel_format
            if channel_datefmt:
                channel_params['datefmt'] = channel_datefmt
            formatter = logger.ChannelOutputFormatter(**channel_params)
            handler = logger.IrcLoggingHandler(self, channel_level)
            handler.setFormatter(formatter)

            # set channel handler to `sopel` logger
            LOGGER = logging.getLogger('sopel')
            LOGGER.addHandler(handler)

    def setup_plugins(self):
        """Load plugins into the bot."""
        load_success = 0
        load_error = 0
        load_disabled = 0

        LOGGER.info("Loading plugins...")
        usable_plugins = plugins.get_usable_plugins(self.settings)
        for name, info in usable_plugins.items():
            plugin, is_enabled = info
            if not is_enabled:
                load_disabled = load_disabled + 1
                continue

            try:
                plugin.load()
            except Exception as e:
                load_error = load_error + 1
                LOGGER.exception("Error loading %s: %s", name, e)
            except SystemExit:
                load_error = load_error + 1
                LOGGER.exception(
                    "Error loading %s (plugin tried to exit)", name)
            else:
                try:
                    if plugin.has_setup():
                        plugin.setup(self)
                        # TODO: remove in Sopel 8
                        self.__setup_plugins_check_manual_url_callbacks(name)
                    plugin.register(self)
                except Exception as e:
                    load_error = load_error + 1
                    LOGGER.exception("Error in %s setup: %s", name, e)
                else:
                    load_success = load_success + 1
                    LOGGER.info("Plugin loaded: %s", name)

        total = sum([load_success, load_error, load_disabled])
        if total and load_success:
            LOGGER.info(
                "Registered %d plugins, %d failed, %d disabled",
                (load_success - 1),
                load_error,
                load_disabled)
        else:
            LOGGER.warning("Warning: Couldn't load any plugins")

    def __setup_plugins_check_manual_url_callbacks(self, name):
        # check if a plugin modified bot.memory['url_callbacks'] manually
        # TODO: remove in Sopel 8
        if 'url_callbacks' not in self.memory:
            # nothing to check
            return

        for key, callback in tools.iteritems(self.memory['url_callbacks']):
            is_checked = getattr(
                callback, '_sopel_url_callbacks_checked', False)
            if is_checked:
                # already checked; move on to next callback
                continue

            # deprecation warning
            LOGGER.warning(
                "Plugin `%s` uses `bot.memory['url_callbacks']`; "
                'this key is deprecated and will be removed in Sopel 8. '
                'Use `@url` or `@url_lazy` instead. Callback was: %s',
                name, callback.__name__)
            # mark callback as checked
            setattr(callback, '_sopel_url_callbacks_checked', True)

    # post setup

    def post_setup(self):
        """Perform post-setup actions.

        This method handles everything that should happen after all the plugins
        are loaded, and before the bot can connect to the IRC server.

        At the moment, this method checks for undefined configuration options,
        and starts the job scheduler.

        .. versionadded:: 7.1
        """
        settings = self.settings
        for section_name, section in settings.get_defined_sections():
            defined_options = {
                settings.parser.optionxform(opt)
                for opt, _ in inspect.getmembers(section)
                if not opt.startswith('_')
            }
            for option_name in settings.parser.options(section_name):
                if option_name not in defined_options:
                    LOGGER.warning(
                        "Config option `%s.%s` is not defined by its section "
                        "and may not be recognized by Sopel.",
                        section_name,
                        option_name,
                    )

        self._scheduler.start()

    # plugins management

    def reload_plugin(self, name):
        """Reload a plugin.

        :param str name: name of the plugin to reload
        :raise plugins.exceptions.PluginNotRegistered: when there is no
            ``name`` plugin registered

        This function runs the plugin's shutdown routine and unregisters the
        plugin from the bot. Then this function reloads the plugin, runs its
        setup routines, and registers it again.
        """
        if not self.has_plugin(name):
            raise plugins.exceptions.PluginNotRegistered(name)

        plugin = self._plugins[name]
        # tear down
        plugin.shutdown(self)
        plugin.unregister(self)
        LOGGER.info("Unloaded plugin %s", name)
        # reload & setup
        plugin.reload()
        plugin.setup(self)
        plugin.register(self)
        meta = plugin.get_meta_description()
        LOGGER.info("Reloaded %s plugin %s from %s",
                    meta['type'], name, meta['source'])

    def reload_plugins(self):
        """Reload all registered plugins.

        First, this function runs all plugin shutdown routines and unregisters
        all plugins. Then it reloads all plugins, runs their setup routines, and
        registers them again.
        """
        registered = list(self._plugins.items())
        # tear down all plugins
        for name, plugin in registered:
            plugin.shutdown(self)
            plugin.unregister(self)
            LOGGER.info("Unloaded plugin %s", name)

        # reload & setup all plugins
        for name, plugin in registered:
            plugin.reload()
            plugin.setup(self)
            plugin.register(self)
            meta = plugin.get_meta_description()
            LOGGER.info("Reloaded %s plugin %s from %s",
                        meta['type'], name, meta['source'])

    def add_plugin(self, plugin, callables, jobs, shutdowns, urls):
        """Add a loaded plugin to the bot's registry.

        :param plugin: loaded plugin to add
        :type plugin: :class:`sopel.plugins.handlers.AbstractPluginHandler`
        :param callables: an iterable of callables from the ``plugin``
        :type callables: :term:`iterable`
        :param jobs: an iterable of functions from the ``plugin`` that are
                     periodically invoked
        :type jobs: :term:`iterable`
        :param shutdowns: an iterable of functions from the ``plugin`` that
                          should be called on shutdown
        :type shutdowns: :term:`iterable`
        :param urls: an iterable of functions from the ``plugin`` to call when
                     matched against a URL
        :type urls: :term:`iterable`
        """
        self._plugins[plugin.name] = plugin
        self.register_callables(callables)
        self.register_jobs(jobs)
        self.register_shutdowns(shutdowns)
        self.register_urls(urls)

    def remove_plugin(self, plugin, callables, jobs, shutdowns, urls):
        """Remove a loaded plugin from the bot's registry.

        :param plugin: loaded plugin to remove
        :type plugin: :class:`sopel.plugins.handlers.AbstractPluginHandler`
        :param callables: an iterable of callables from the ``plugin``
        :type callables: :term:`iterable`
        :param jobs: an iterable of functions from the ``plugin`` that are
                     periodically invoked
        :type jobs: :term:`iterable`
        :param shutdowns: an iterable of functions from the ``plugin`` that
                          should be called on shutdown
        :type shutdowns: :term:`iterable`
        :param urls: an iterable of functions from the ``plugin`` to call when
                     matched against a URL
        :type urls: :term:`iterable`
        """
        name = plugin.name
        if not self.has_plugin(name):
            raise plugins.exceptions.PluginNotRegistered(name)

        # remove plugin rules, jobs, shutdown functions, and url callbacks
        self._rules_manager.unregister_plugin(name)
        self._scheduler.unregister_plugin(name)
        self.unregister_shutdowns(shutdowns)

        # remove plugin from registry
        del self._plugins[name]

    def has_plugin(self, name):
        """Check if the bot has registered a plugin of the specified name.

        :param str name: name of the plugin to check for
        :return: whether the bot has a plugin named ``name`` registered
        :rtype: bool
        """
        return name in self._plugins

    def get_plugin_meta(self, name):
        """Get info about a registered plugin by its name.

        :param str name: name of the plugin about which to get info
        :return: the plugin's metadata
                 (see :meth:`~.plugins.handlers.AbstractPluginHandler.get_meta_description`)
        :rtype: :class:`dict`
        :raise plugins.exceptions.PluginNotRegistered: when there is no
            ``name`` plugin registered
        """
        if not self.has_plugin(name):
            raise plugins.exceptions.PluginNotRegistered(name)

        return self._plugins[name].get_meta_description()

    # callable management

    @deprecated(
        reason="Replaced by specific `unregister_*` methods.",
        version='7.1',
        removed_in='8.0')
    def unregister(self, obj):
        """Unregister a shutdown method.

        :param obj: the shutdown method to unregister
        :type obj: :term:`object`

        This method was used to unregister anything (rules, commands, urls,
        jobs, and shutdown methods), but since everything can be done by other
        means, there is no use for it anymore.
        """
        callable_name = getattr(obj, "__name__", 'UNKNOWN')

        if hasattr(obj, 'interval'):
            self.unregister_jobs([obj])

        if callable_name == "shutdown" and obj in self.shutdown_methods:
            self.unregister_shutdowns([obj])

    @deprecated(
        reason="Replaced by specific `register_*` methods.",
        version='7.1',
        removed_in='8.0')
    def register(self, callables, jobs, shutdowns, urls):
        """Register rules, jobs, shutdown methods, and URL callbacks.

        :param callables: an iterable of callables to register
        :type callables: :term:`iterable`
        :param jobs: an iterable of functions to periodically invoke
        :type jobs: :term:`iterable`
        :param shutdowns: an iterable of functions to call on shutdown
        :type shutdowns: :term:`iterable`
        :param urls: an iterable of functions to call when matched against a URL
        :type urls: :term:`iterable`

        The ``callables`` argument contains a list of "callable objects", i.e.
        objects for which :func:`callable` will return ``True``. They can be:

        * a callable with rules (will match triggers with a regex pattern)
        * a callable without rules (will match any triggers, such as events)
        * a callable with commands
        * a callable with nick commands
        * a callable with action commands

        It is possible to have a callable with rules, commands, and nick
        commands configured. It should not be possible to have a callable with
        commands or nick commands but without rules.
        """
        self.register_callables(callables)
        self.register_jobs(jobs)
        self.register_shutdowns(shutdowns)
        self.register_urls(urls)

    def register_callables(self, callables):
        match_any = re.compile(r'.*')
        settings = self.settings

        for callbl in callables:
            rules = getattr(callbl, 'rule', [])
            lazy_rules = getattr(callbl, 'rule_lazy_loaders', [])
            find_rules = getattr(callbl, 'find_rules', [])
            lazy_find_rules = getattr(callbl, 'find_rules_lazy_loaders', [])
            search_rules = getattr(callbl, 'search_rules', [])
            lazy_search_rules = getattr(callbl, 'search_rules_lazy_loaders', [])
            commands = getattr(callbl, 'commands', [])
            nick_commands = getattr(callbl, 'nickname_commands', [])
            action_commands = getattr(callbl, 'action_commands', [])
            is_rule = any([
                rules,
                lazy_rules,
                find_rules,
                lazy_find_rules,
                search_rules,
                lazy_search_rules,
            ])
            is_command = any([commands, nick_commands, action_commands])

            if rules:
                rule = plugin_rules.Rule.from_callable(settings, callbl)
                self._rules_manager.register(rule)

            if lazy_rules:
                try:
                    rule = plugin_rules.Rule.from_callable_lazy(
                        settings, callbl)
                    self._rules_manager.register(rule)
                except plugins.exceptions.PluginError as err:
                    LOGGER.error('Cannot register rule: %s', err)

            if find_rules:
                rule = plugin_rules.FindRule.from_callable(settings, callbl)
                self._rules_manager.register(rule)

            if lazy_find_rules:
                try:
                    rule = plugin_rules.FindRule.from_callable_lazy(
                        settings, callbl)
                    self._rules_manager.register(rule)
                except plugins.exceptions.PluginError as err:
                    LOGGER.error('Cannot register find rule: %s', err)

            if search_rules:
                rule = plugin_rules.SearchRule.from_callable(settings, callbl)
                self._rules_manager.register(rule)

            if lazy_search_rules:
                try:
                    rule = plugin_rules.SearchRule.from_callable_lazy(
                        settings, callbl)
                    self._rules_manager.register(rule)
                except plugins.exceptions.PluginError as err:
                    LOGGER.error('Cannot register search rule: %s', err)

            if commands:
                rule = plugin_rules.Command.from_callable(settings, callbl)
                self._rules_manager.register_command(rule)

            if nick_commands:
                rule = plugin_rules.NickCommand.from_callable(
                    settings, callbl)
                self._rules_manager.register_nick_command(rule)

            if action_commands:
                rule = plugin_rules.ActionCommand.from_callable(
                    settings, callbl)
                self._rules_manager.register_action_command(rule)

            if not is_command and not is_rule:
                callbl.rule = [match_any]
                self._rules_manager.register(
                    plugin_rules.Rule.from_callable(self.settings, callbl))

    def register_jobs(self, jobs):
        for func in jobs:
            job = sopel.tools.jobs.Job.from_callable(self.settings, func)
            self._scheduler.register(job)

    def unregister_jobs(self, jobs):
        for job in jobs:
            self._scheduler.remove_callable_job(job)

    def register_shutdowns(self, shutdowns):
        # Append plugin's shutdown function to the bot's list of functions to
        # call on shutdown
        self.shutdown_methods = self.shutdown_methods + list(shutdowns)

    def unregister_shutdowns(self, shutdowns):
        self.shutdown_methods = [
            shutdown
            for shutdown in self.shutdown_methods
            if shutdown not in shutdowns
        ]

    def register_urls(self, urls):
        for func in urls:
            url_regex = getattr(func, 'url_regex', [])
            url_lazy_loaders = getattr(func, 'url_lazy_loaders', None)

            if url_regex:
                rule = plugin_rules.URLCallback.from_callable(
                    self.settings, func)
                self._rules_manager.register_url_callback(rule)

            if url_lazy_loaders:
                try:
                    rule = plugin_rules.URLCallback.from_callable_lazy(
                        self.settings, func)
                    self._rules_manager.register_url_callback(rule)
                except plugins.exceptions.PluginError as err:
                    LOGGER.error("Cannot register URL callback: %s", err)

    @deprecated(
        reason="Replaced by `say` method.",
        version='6.0',
        removed_in='8.0')
    def msg(self, recipient, text, max_messages=1):
        """Old way to make the bot say something on IRC.

        :param str recipient: nickname or channel to which to send message
        :param str text: message to send
        :param int max_messages: split ``text`` into at most this many messages
                                 if it is too long to fit in one (optional)

        .. deprecated:: 6.0
            Use :meth:`say` instead. Will be removed in Sopel 8.
        """
        self.say(text, recipient, max_messages)

    # message dispatch

    def call_rule(self, rule, sopel, trigger):
        # rate limiting
        if not trigger.admin and not rule.is_unblockable():
            if rule.is_rate_limited(trigger.nick):
                return
            if not trigger.is_privmsg and rule.is_channel_rate_limited(trigger.sender):
                return
            if rule.is_global_rate_limited():
                return

        # channel config
        if trigger.sender in self.config:
            channel_config = self.config[trigger.sender]

            # disable listed plugins completely on provided channel
            if 'disable_plugins' in channel_config:
                disabled_plugins = channel_config.disable_plugins.split(',')

                if '*' in disabled_plugins:
                    return
                elif rule.get_plugin_name() in disabled_plugins:
                    return

            # disable chosen methods from plugins
            if 'disable_commands' in channel_config:
                disabled_commands = literal_eval(channel_config.disable_commands)
                disabled_commands = disabled_commands.get(rule.get_plugin_name(), [])
                if rule.get_rule_label() in disabled_commands:
                    return

        try:
            rule.execute(sopel, trigger)
        except KeyboardInterrupt:
            raise
        except Exception as error:
            self.error(trigger, exception=error)

    def call(self, func, sopel, trigger):
        """Call a function, applying any rate limits or other restrictions.

        :param func: the function to call
        :type func: :term:`function`
        :param sopel: a SopelWrapper instance
        :type sopel: :class:`SopelWrapper`
        :param Trigger trigger: the Trigger object for the line from the server
                                that triggered this call
        """
        nick = trigger.nick
        current_time = time.time()
        if nick not in self._times:
            self._times[nick] = dict()
        if self.nick not in self._times:
            self._times[self.nick] = dict()
        if not trigger.is_privmsg and trigger.sender not in self._times:
            self._times[trigger.sender] = dict()

        if not trigger.admin and not func.unblockable:
            if func in self._times[nick]:
                usertimediff = current_time - self._times[nick][func]
                if func.rate > 0 and usertimediff < func.rate:
                    LOGGER.info(
                        "%s prevented from using %s in %s due to user limit: %d < %d",
                        trigger.nick, func.__name__, trigger.sender, usertimediff,
                        func.rate
                    )
                    return
            if func in self._times[self.nick]:
                globaltimediff = current_time - self._times[self.nick][func]
                if func.global_rate > 0 and globaltimediff < func.global_rate:
                    LOGGER.info(
                        "%s prevented from using %s in %s due to global limit: %d < %d",
                        trigger.nick, func.__name__, trigger.sender, globaltimediff,
                        func.global_rate
                    )
                    return

            if not trigger.is_privmsg and func in self._times[trigger.sender]:
                chantimediff = current_time - self._times[trigger.sender][func]
                if func.channel_rate > 0 and chantimediff < func.channel_rate:
                    LOGGER.info(
                        "%s prevented from using %s in %s due to channel limit: %d < %d",
                        trigger.nick, func.__name__, trigger.sender, chantimediff,
                        func.channel_rate
                    )
                    return

        # if channel has its own config section, check for excluded plugins/plugin methods
        if trigger.sender in self.config:
            channel_config = self.config[trigger.sender]
            LOGGER.debug(
                "Evaluating configuration for %s.%s in channel %s",
                func.plugin_name, func.__name__, trigger.sender
            )

            # disable listed plugins completely on provided channel
            if 'disable_plugins' in channel_config:
                disabled_plugins = channel_config.disable_plugins.split(',')

                # if "*" is used, we are disabling all plugins on provided channel
                if '*' in disabled_plugins:
                    LOGGER.debug(
                        "All plugins disabled in %s; skipping execution of %s.%s",
                        trigger.sender, func.plugin_name, func.__name__
                    )
                    return
                if func.plugin_name in disabled_plugins:
                    LOGGER.debug(
                        "Plugin %s is disabled in %s; skipping execution of %s",
                        func.plugin_name, trigger.sender, func.__name__
                    )
                    return

            # disable chosen methods from plugins
            if 'disable_commands' in channel_config:
                disabled_commands = literal_eval(channel_config.disable_commands)

                if func.plugin_name in disabled_commands:
                    if func.__name__ in disabled_commands[func.plugin_name]:
                        LOGGER.debug(
                            "Skipping execution of %s.%s in %s: disabled_commands matched",
                            func.plugin_name, func.__name__, trigger.sender
                        )
                        return

        try:
            exit_code = func(sopel, trigger)
        except Exception as error:  # TODO: Be specific
            exit_code = None
            self.error(trigger, exception=error)

        if exit_code != NOLIMIT:
            self._times[nick][func] = current_time
            self._times[self.nick][func] = current_time
            if not trigger.is_privmsg:
                self._times[trigger.sender][func] = current_time

    def _is_pretrigger_blocked(self, pretrigger):
        if self.settings.core.nick_blocks or self.settings.core.host_blocks:
            nick_blocked = self._nick_blocked(pretrigger.nick)
            host_blocked = self._host_blocked(pretrigger.host)
        else:
            nick_blocked = host_blocked = None

        return (nick_blocked, host_blocked)

    def dispatch(self, pretrigger):
        """Dispatch a parsed message to any registered callables.

        :param pretrigger: a parsed message from the server
        :type pretrigger: :class:`~sopel.trigger.PreTrigger`

        The ``pretrigger`` (a parsed message) is used to find matching rules;
        it will retrieve them by order of priority, and execute them. It runs
        triggered rules in separate threads, unless they are marked otherwise.

        However, it won't run triggered blockable rules at all when they can't
        be executed for blocked nickname or hostname.

        .. seealso::

            The pattern matching is done by the
            :class:`Rules Manager<sopel.plugins.rules.Manager>`.

        """
        # list of commands running in separate threads for this dispatch
        running_triggers = []
        # nickname/hostname blocking
        nick_blocked, host_blocked = self._is_pretrigger_blocked(pretrigger)
        blocked = bool(nick_blocked or host_blocked)
        list_of_blocked_rules = set()
        # account info
        nick = pretrigger.nick
        user_obj = self.users.get(nick)
        account = user_obj.account if user_obj else None

        for rule, match in self._rules_manager.get_triggered_rules(self, pretrigger):
            trigger = Trigger(self.settings, pretrigger, match, account)

            is_unblockable = trigger.admin or rule.is_unblockable()
            if blocked and not is_unblockable:
                list_of_blocked_rules.add(str(rule))
                continue

            wrapper = SopelWrapper(
                self, trigger, output_prefix=rule.get_output_prefix())

            if rule.is_threaded():
                # run in a separate thread
                targs = (rule, wrapper, trigger)
                t = threading.Thread(target=self.call_rule, args=targs)
                plugin_name = rule.get_plugin_name()
                rule_label = rule.get_rule_label()
                t.name = '%s-%s-%s' % (t.name, plugin_name, rule_label)
                t.start()
                running_triggers.append(t)
            else:
                # direct call
                self.call_rule(rule, wrapper, trigger)

        # update currently running triggers
        self._update_running_triggers(running_triggers)

        if list_of_blocked_rules:
            if nick_blocked and host_blocked:
                block_type = 'both blocklists'
            elif nick_blocked:
                block_type = 'nick blocklist'
            else:
                block_type = 'host blocklist'
            LOGGER.debug(
                "%s prevented from using %s by %s.",
                pretrigger.nick,
                ', '.join(list_of_blocked_rules),
                block_type,
            )

    @property
    def running_triggers(self):
        """Current active threads for triggers.

        :return: the running thread(s) currently processing trigger(s)
        :rtype: :term:`iterable`

        This is for testing and debugging purposes only.
        """
        with self._running_triggers_lock:
            return [t for t in self._running_triggers if t.is_alive()]

    def _update_running_triggers(self, running_triggers):
        """Update list of running triggers.

        :param list running_triggers: newly started threads

        We want to keep track of running triggers, mostly for testing and
        debugging purposes. For instance, it'll help make sure, in tests, that
        a bot plugin has finished processing a trigger, by manually joining
        all running threads.

        This is kept private, as it's purely internal machinery and isn't
        meant to be manipulated by outside code.
        """
        # update bot's global running triggers
        with self._running_triggers_lock:
            running_triggers = running_triggers + self._running_triggers
            self._running_triggers = [
                t for t in running_triggers if t.is_alive()]

    # event handlers

    def on_scheduler_error(self, scheduler, exc):
        """Called when the Job Scheduler fails.

        :param scheduler: the job scheduler that errored
        :type scheduler: :class:`sopel.plugins.jobs.Scheduler`
        :param Exception exc: the raised exception

        .. seealso::

            :meth:`Sopel.error`
        """
        self.error(exception=exc)

    def on_job_error(self, scheduler, job, exc):
        """Called when a job from the Job Scheduler fails.

        :param scheduler: the job scheduler responsible for the errored ``job``
        :type scheduler: :class:`sopel.plugins.jobs.Scheduler`
        :param job: the Job that errored
        :type job: :class:`sopel.tools.jobs.Job`
        :param Exception exc: the raised exception

        .. seealso::

            :meth:`Sopel.error`
        """
        self.error(exception=exc)

    def error(self, trigger=None, exception=None):
        """Called internally when a plugin causes an error.

        :param trigger: the ``Trigger``\\ing line (if available)
        :type trigger: :class:`sopel.trigger.Trigger`
        :param Exception exception: the exception raised by the error (if
                                    available)
        """
        message = 'Unexpected error'
        if exception:
            message = '{} ({})'.format(message, exception)

        if trigger:
            message = '{} from {} at {}. Message was: {}'.format(
                message, trigger.nick, str(datetime.utcnow()), trigger.group(0)
            )

        LOGGER.exception(message)

        if trigger and self.settings.core.reply_errors and trigger.sender is not None:
            self.say(message, trigger.sender)

    def _host_blocked(self, host):
        """Check if a hostname is blocked.

        :param str host: the hostname to check
        """
        bad_masks = self.config.core.host_blocks
        for bad_mask in bad_masks:
            bad_mask = bad_mask.strip()
            if not bad_mask:
                continue
            if (re.match(bad_mask + '$', host, re.IGNORECASE) or
                    bad_mask == host):
                return True
        return False

    def _nick_blocked(self, nick):
        """Check if a nickname is blocked.

        :param str nick: the nickname to check
        """
        bad_nicks = self.config.core.nick_blocks
        for bad_nick in bad_nicks:
            bad_nick = bad_nick.strip()
            if not bad_nick:
                continue
            if (re.match(bad_nick + '$', nick, re.IGNORECASE) or
                    Identifier(bad_nick) == nick):
                return True
        return False

    def _shutdown(self):
        """Internal bot shutdown method."""
        LOGGER.info("Shutting down")
        # Stop Job Scheduler
        LOGGER.info("Stopping the Job Scheduler.")
        self._scheduler.stop()

        try:
            self._scheduler.join(timeout=15)
        except RuntimeError:
            LOGGER.exception("Unable to stop the Job Scheduler.")
        else:
            LOGGER.info("Job Scheduler stopped.")

        self._scheduler.clear_jobs()

        # Shutdown plugins
        LOGGER.info(
            "Calling shutdown for %d plugins.", len(self.shutdown_methods))

        for shutdown_method in self.shutdown_methods:
            try:
                LOGGER.debug(
                    "Calling %s.%s",
                    shutdown_method.__module__,
                    shutdown_method.__name__)
                shutdown_method(self)
            except Exception as e:
                LOGGER.exception("Error calling shutdown method: %s", e)

        # Avoid calling shutdown methods if we already have.
        self.shutdown_methods = []

    # URL callbacks management

    @deprecated(
        reason='Issues with @url decorator have been fixed. Simply use that.',
        version='7.1',
        warning_in='8.0',
        removed_in='9.0',
    )
    def register_url_callback(self, pattern, callback):
        """Register a ``callback`` for URLs matching the regex ``pattern``.

        :param pattern: compiled regex pattern to register
        :type pattern: :ref:`re.Pattern <python:re-objects>`
        :param callback: callable object to handle matching URLs
        :type callback: :term:`function`

        .. versionadded:: 7.0

            This method replaces manual management of ``url_callbacks`` in
            Sopel's plugins, so instead of doing this in ``setup()``::

                if 'url_callbacks' not in bot.memory:
                    bot.memory['url_callbacks'] = tools.SopelMemory()

                regex = re.compile(r'http://example.com/path/.*')
                bot.memory['url_callbacks'][regex] = callback

            use this much more concise pattern::

                regex = re.compile(r'http://example.com/path/.*')
                bot.register_url_callback(regex, callback)

        It's recommended you completely avoid manual management of URL
        callbacks through the use of :func:`sopel.plugin.url`.

        .. deprecated:: 7.1

            Made obsolete by fixes to the behavior of
            :func:`sopel.plugin.url`. Will be removed in Sopel 9.

        """
        if 'url_callbacks' not in self.memory:
            self.memory['url_callbacks'] = tools.SopelMemory()

        if isinstance(pattern, basestring):
            pattern = re.compile(pattern)

        # Mark the callback as checked: using this method is safe.
        # TODO: remove in Sopel 8
        setattr(callback, '_sopel_url_callbacks_checked', True)
        self.memory['url_callbacks'][pattern] = callback

    @deprecated(
        reason='Issues with @url decorator have been fixed. Simply use that.',
        version='7.1',
        warning_in='8.0',
        removed_in='9.0',
    )
    def unregister_url_callback(self, pattern, callback):
        """Unregister the callback for URLs matching the regex ``pattern``.

        :param pattern: compiled regex pattern to unregister callback
        :type pattern: :ref:`re.Pattern <python:re-objects>`
        :param callback: callable object to remove
        :type callback: :term:`function`

        .. versionadded:: 7.0

            This method replaces manual management of ``url_callbacks`` in
            Sopel's plugins, so instead of doing this in ``shutdown()``::

                regex = re.compile(r'http://example.com/path/.*')
                try:
                    del bot.memory['url_callbacks'][regex]
                except KeyError:
                    pass

            use this much more concise pattern::

                regex = re.compile(r'http://example.com/path/.*')
                bot.unregister_url_callback(regex, callback)

        It's recommended you completely avoid manual management of URL
        callbacks through the use of :func:`sopel.plugin.url`.

        .. deprecated:: 7.1

            Made obsolete by fixes to the behavior of
            :func:`sopel.plugin.url`. Will be removed in Sopel 9.

        """
        if 'url_callbacks' not in self.memory:
            # nothing to unregister
            return

        if isinstance(pattern, basestring):
            pattern = re.compile(pattern)

        try:
            del self.memory['url_callbacks'][pattern]
        except KeyError:
            pass

    def search_url_callbacks(self, url):
        """Yield callbacks whose regex pattern matches the ``url``.

        :param str url: URL found in a trigger
        :return: yield 2-value tuples of ``(callback, match)``

        For each pattern that matches the ``url`` parameter, it yields a
        2-value tuple of ``(callable, match)`` for that pattern.

        The ``callable`` is the one registered with
        :meth:`register_url_callback`, and the ``match`` is the result of
        the regex pattern's ``search`` method.

        .. versionadded:: 7.0

        .. seealso::

            The Python documentation for the `re.search`__ function and
            the `match object`__.

        .. __: https://docs.python.org/3.6/library/re.html#re.search
        .. __: https://docs.python.org/3.6/library/re.html#match-objects

        """
        if 'url_callbacks' not in self.memory:
            # nothing to search
            return

        for regex, function in tools.iteritems(self.memory['url_callbacks']):
            match = regex.search(url)
            if match:
                yield function, match

    def restart(self, message):
        """Disconnect from IRC and restart the bot.

        :param str message: QUIT message to send (e.g. "Be right back!")
        """
        self.wantsrestart = True
        self.quit(message)


class SopelWrapper(object):
    """Wrapper around a Sopel instance and a Trigger.

    :param sopel: Sopel instance
    :type sopel: :class:`~sopel.bot.Sopel`
    :param trigger: IRC Trigger line
    :type trigger: :class:`~sopel.trigger.Trigger`
    :param str output_prefix: prefix for messages sent through this wrapper
                              (e.g. plugin tag)

    This wrapper will be used to call Sopel's triggered commands and rules as
    their ``bot`` argument. It acts as a proxy to :meth:`send messages<say>`
    to the sender (either a channel or in a private message) and even to
    :meth:`reply to someone<reply>` in a channel.
    """
    def __init__(self, sopel, trigger, output_prefix=''):
        if not output_prefix:
            # Just in case someone passes in False, None, etc.
            output_prefix = ''
        # The custom __setattr__ for this class sets the attribute on the
        # original bot object. We don't want that for these, so we set them
        # with the normal __setattr__.
        object.__setattr__(self, '_bot', sopel)
        object.__setattr__(self, '_trigger', trigger)
        object.__setattr__(self, '_out_pfx', output_prefix)

    def __dir__(self):
        classattrs = [attr for attr in self.__class__.__dict__
                      if not attr.startswith('__')]
        return list(self.__dict__) + classattrs + dir(self._bot)

    def __getattr__(self, attr):
        return getattr(self._bot, attr)

    def __setattr__(self, attr, value):
        return setattr(self._bot, attr, value)

    def say(self, message, destination=None, max_messages=1, truncation='', trailing=''):
        """Override ``Sopel.say`` to use trigger source by default.

        :param str message: message to say
        :param str destination: channel or nickname; defaults to
            :attr:`trigger.sender <sopel.trigger.Trigger.sender>`
        :param int max_messages: split ``message`` into at most this many
                                 messages if it is too long to fit into one
                                 line (optional)
        :param str truncation: string to indicate that the ``message`` was
                               truncated (optional)
        :param str trailing: string that should always appear at the end of
                             ``message`` (optional)

        The ``destination`` will default to the channel in which the
        trigger happened (or nickname, if received in a private message).

        .. seealso::

            For more details about the optional arguments to this wrapper
            method, consult the documentation for :meth:`sopel.bot.Sopel.say`.

        """
        if destination is None:
            destination = self._trigger.sender
        self._bot.say(self._out_pfx + message, destination, max_messages, truncation, trailing)

    def action(self, message, destination=None):
        """Override ``Sopel.action`` to use trigger source by default.

        :param str message: action message
        :param str destination: channel or nickname; defaults to
            :attr:`trigger.sender <sopel.trigger.Trigger.sender>`

        The ``destination`` will default to the channel in which the
        trigger happened (or nickname, if received in a private message).

        .. seealso::

            :meth:`sopel.bot.Sopel.action`
        """
        if destination is None:
            destination = self._trigger.sender
        self._bot.action(message, destination)

    def notice(self, message, destination=None):
        """Override ``Sopel.notice`` to use trigger source by default.

        :param str message: notice message
        :param str destination: channel or nickname; defaults to
            :attr:`trigger.sender <sopel.trigger.Trigger.sender>`

        The ``destination`` will default to the channel in which the
        trigger happened (or nickname, if received in a private message).

        .. seealso::

            :meth:`sopel.bot.Sopel.notice`
        """
        if destination is None:
            destination = self._trigger.sender
        self._bot.notice(self._out_pfx + message, destination)

    def reply(self, message, destination=None, reply_to=None, notice=False):
        """Override ``Sopel.reply`` to ``reply_to`` sender by default.

        :param str message: reply message
        :param str destination: channel or nickname; defaults to
            :attr:`trigger.sender <sopel.trigger.Trigger.sender>`
        :param str reply_to: person to reply to; defaults to
            :attr:`trigger.nick <sopel.trigger.Trigger.nick>`
        :param bool notice: reply as an IRC notice or with a simple message

        The ``destination`` will default to the channel in which the
        trigger happened (or nickname, if received in a private message).

        ``reply_to`` will default to the nickname who sent the trigger.

        .. seealso::

            :meth:`sopel.bot.Sopel.reply`
        """
        if destination is None:
            destination = self._trigger.sender
        if reply_to is None:
            reply_to = self._trigger.nick
        self._bot.reply(message, destination, reply_to, notice)

    def kick(self, nick, channel=None, message=None):
        """Override ``Sopel.kick`` to kick in a channel

        :param str nick: nick to kick out of the ``channel``
        :param str channel: optional channel to kick ``nick`` from
        :param str message: optional message for the kick

        The ``channel`` will default to the channel in which the call was
        triggered. If triggered from a private message, ``channel`` is
        required.

        .. seealso::

            :meth:`sopel.bot.Sopel.kick`
        """
        if channel is None:
            if self._trigger.is_privmsg:
                raise RuntimeError('Error: KICK requires a channel.')
            else:
                channel = self._trigger.sender
        if nick is None:
            raise RuntimeError('Error: KICK requires a nick.')
        self._bot.kick(nick, channel, message)
