"""
CmdSethandler

The Cmdsethandler tracks an object's 'Current CmdSet', which is the
current merged sum of all CmdSets added to it.

A CmdSet constitues a set of commands. The CmdSet works as a special
intelligent container that, when added to other CmdSet make sure that
same-name commands are treated correctly (usually so there are no
doublets).  This temporary but up-to-date merger of CmdSet is jointly
called the Current Cmset. It is this Current CmdSet that the
commandhandler looks through whenever an account enters a command (it
also adds CmdSets from objects in the room in real-time). All account
objects have a 'default cmdset' containing all the normal in-game mud
commands (look etc).

So what is all this cmdset complexity good for?

In its simplest form, a CmdSet has no commands, only a key name. In
this case the cmdset's use is up to each individual game - it can be
used by an AI module for example (mobs in cmdset 'roam' move from room
to room, in cmdset 'attack' they enter combat with accounts).

Defining commands in cmdsets offer some further powerful game-design
consequences however. Here are some examples:

As mentioned above, all accounts always have at least the Default
CmdSet.  This contains the set of all normal-use commands in-game,
stuff like look and @desc etc. Now assume our players end up in a dark
room. You don't want the player to be able to do much in that dark
room unless they light a candle. You could handle this by changing all
your normal commands to check if the player is in a dark room. This
rapidly goes unwieldly and error prone. Instead you just define a
cmdset with only those commands you want to be available in the 'dark'
cmdset - maybe a modified look command and a 'light candle' command -
and have this completely replace the default cmdset.

Another example: Say you want your players to be able to go
fishing. You could implement this as a 'fish' command that fails
whenever the account has no fishing rod. Easy enough.  But what if you
want to make fishing more complex - maybe you want four-five different
commands for throwing your line, reeling in, etc? Most players won't
(we assume) have fishing gear, and having all those detailed commands
is cluttering up the command list. And what if you want to use the
'throw' command also for throwing rocks etc instead of 'using it up'
for a minor thing like fishing?

So instead you put all those detailed fishing commands into their own
CommandSet called 'Fishing'. Whenever the player gives the command
'fish' (presumably the code checks there is also water nearby), only
THEN this CommandSet is added to the Cmdhandler of the account. The
'throw' command (which normally throws rocks) is replaced by the
custom 'fishing variant' of throw. What has happened is that the
Fishing CommandSet was merged on top of the Default ones, and due to
how we defined it, its command overrules the default ones.

When we are tired of fishing, we give the 'go home' command (or
whatever) and the Cmdhandler simply removes the fishing CommandSet
so that we are back at defaults (and can throw rocks again).

Since any number of CommandSets can be piled on top of each other, you
can then implement separate sets for different situations. For
example, you can have a 'On a boat' set, onto which you then tack on
the 'Fishing' set. Fishing from a boat? No problem!

"""
import sys
from importlib import import_module
from inspect import trace
from traceback import format_exc

from django.conf import settings
from django.utils.translation import gettext as _

from evennia.commands.cmdset import CmdSet
from evennia.server.models import ServerConfig
from evennia.utils import logger, utils

__all__ = ("import_cmdset", "CmdSetHandler")

_CACHED_CMDSETS = {}
_CMDSET_PATHS = utils.make_iter(settings.CMDSET_PATHS)
_IN_GAME_ERRORS = settings.IN_GAME_ERRORS
_CMDSET_FALLBACKS = settings.CMDSET_FALLBACKS


# Output strings

_ERROR_CMDSET_IMPORT = _(
    """{traceback}
Error loading cmdset '{path}'
(Traceback was logged {timestamp})"""
)

_ERROR_CMDSET_KEYERROR = _(
    """Error loading cmdset: No cmdset class '{classname}' in '{path}'.
(Traceback was logged {timestamp})"""
)

_ERROR_CMDSET_SYNTAXERROR = _(
    """{traceback}
SyntaxError encountered when loading cmdset '{path}'.
(Traceback was logged {timestamp})"""
)

_ERROR_CMDSET_EXCEPTION = _(
    """{traceback}
Compile/Run error when loading cmdset '{path}'.
(Traceback was logged {timestamp})"""
)

_ERROR_CMDSET_FALLBACK = _(
    """
Error encountered for cmdset at path '{path}'.
Replacing with fallback '{fallback_path}'.
"""
)

_ERROR_CMDSET_NO_FALLBACK = _("""Fallback path '{fallback_path}' failed to generate a cmdset.""")


class _ErrorCmdSet(CmdSet):
    """
    This is a special cmdset used to report errors.
    """

    key = "_CMDSET_ERROR"
    errmessage = "Error when loading cmdset."


class _EmptyCmdSet(CmdSet):
    """
    This cmdset represents an empty cmdset
    """

    key = "_EMPTY_CMDSET"
    priority = -101
    mergetype = "Union"


def import_cmdset(path, cmdsetobj, emit_to_obj=None, no_logging=False):
    """
    This helper function is used by the cmdsethandler to load a cmdset
    instance from a python module, given a python_path. It's usually accessed
    through the cmdsethandler's add() and add_default() methods.
    path - This is the full path to the cmdset object on python dot-form

    Args:
        path (str): The path to the command set to load.
        cmdsetobj (CmdSet): The database object/typeclass on which this cmdset is to be
            assigned (this can be also channels and exits, as well as accounts
            but there will always be such an object)
        emit_to_obj (Object, optional): If given, error is emitted to
            this object (in addition to logging)
        no_logging (bool, optional): Don't log/send error messages.
            This can be useful if import_cmdset is just used to check if
            this is a valid python path or not.
    Returns:
        cmdset (CmdSet): The imported command set. If an error was
            encountered, `commands.cmdsethandler._ErrorCmdSet` is returned
            for the benefit of the handler.

    """
    python_paths = [path] + [
        "%s.%s" % (prefix, path) for prefix in _CMDSET_PATHS if not path.startswith(prefix)
    ]
    errstring = ""
    for python_path in python_paths:
        if "." in path:
            modpath, classname = python_path.rsplit(".", 1)
        else:
            raise ImportError(f"The path '{path}' is not on the form modulepath.ClassName")

        try:
            # first try to get from cache
            cmdsetclass = _CACHED_CMDSETS.get(python_path, None)

            if not cmdsetclass:
                try:
                    module = import_module(modpath, package="evennia")
                except ImportError as exc:
                    if len(trace()) > 2:
                        # error in module, make sure to not hide it.
                        dum, dum, tb = sys.exc_info()
                        raise exc.with_traceback(tb)
                    else:
                        # try next suggested path
                        errstring += _("\n(Unsuccessfully tried '{path}').").format(
                            path=python_path
                        )
                        continue
                try:
                    cmdsetclass = getattr(module, classname)
                except AttributeError as exc:
                    if len(trace()) > 2:
                        # Attribute error within module, don't hide it
                        dum, dum, tb = sys.exc_info()
                        raise exc.with_traceback(tb)
                    else:
                        errstring += _("\n(Unsuccessfully tried '{path}').").format(
                            path=python_path
                        )
                        continue
                _CACHED_CMDSETS[python_path] = cmdsetclass

            # instantiate the cmdset (and catch its errors)
            if callable(cmdsetclass):
                cmdsetclass = cmdsetclass(cmdsetobj)
            return cmdsetclass
        except ImportError as err:
            logger.log_trace()
            errstring += _ERROR_CMDSET_IMPORT
            if _IN_GAME_ERRORS:
                errstring = errstring.format(
                    path=python_path, traceback=format_exc(), timestamp=logger.timeformat()
                )
            else:
                errstring = errstring.format(
                    path=python_path, traceback=err, timestamp=logger.timeformat()
                )
            break
        except KeyError:
            logger.log_trace()
            errstring += _ERROR_CMDSET_KEYERROR
            errstring = errstring.format(
                classname=classname, path=python_path, timestamp=logger.timeformat()
            )
            break
        except SyntaxError as err:
            logger.log_trace()
            errstring += _ERROR_CMDSET_SYNTAXERROR
            if _IN_GAME_ERRORS:
                errstring = errstring.format(
                    path=python_path, traceback=format_exc(), timestamp=logger.timeformat()
                )
            else:
                errstring = errstring.format(
                    path=python_path, traceback=err, timestamp=logger.timeformat()
                )
            break
        except Exception as err:
            logger.log_trace()
            errstring += _ERROR_CMDSET_EXCEPTION
            if _IN_GAME_ERRORS:
                errstring = errstring.format(
                    path=python_path, traceback=format_exc(), timestamp=logger.timeformat()
                )
            else:
                errstring = errstring.format(
                    path=python_path, traceback=err, timestamp=logger.timeformat()
                )
            break

    if errstring:
        # returning an empty error cmdset
        errstring = errstring.strip()
        if not no_logging:
            logger.log_err(errstring)
            if emit_to_obj and not ServerConfig.objects.conf("server_starting_mode"):
                emit_to_obj.msg(errstring)
        err_cmdset = _ErrorCmdSet()
        err_cmdset.errmessage = errstring
        return err_cmdset
    return None  # undefined error


# classes


class CmdSetHandler(object):
    """
    The CmdSetHandler is always stored on an object, this object is supplied
    as an argument.

    The 'current' cmdset is the merged set currently active for this object.
    This is the set the game engine will retrieve when determining which
    commands are available to the object. The cmdset_stack holds a history of
    all CmdSets to allow the handler to remove/add cmdsets at will. Doing so
    will re-calculate the 'current' cmdset.
    """

    def __init__(self, obj, init_true=True):
        """
        This method is called whenever an object is recreated.

        Args:
            obj (Object): An reference to the game object this handler
                belongs to.
            init_true (bool, optional): Set when the handler is initializing
                and loads the current cmdset.

        """
        self.obj = obj

        # the id of the "merged" current cmdset for easy access.
        self.key = None
        # this holds the "merged" current command set. Note that while the .update
        # method updates this field in order to have it synced when operating on
        # cmdsets in-code, when the game runs, this field is kept up-to-date by
        # the cmdsethandler's get_and_merge_cmdsets!
        self.current = None
        # this holds a history of CommandSets
        self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)]
        # this tracks which mergetypes are actually in play in the stack
        self.mergetype_stack = ["Union"]

        # the subset of the cmdset_paths that are to be stored in the database
        self.persistent_paths = [""]

        if init_true:
            self.update(init_mode=True)  # is then called from the object __init__.

    def __str__(self):
        """
        Display current commands

        """

        strings = ["<CmdSetHandler> stack:"]
        mergelist = []
        if len(self.cmdset_stack) > 1:
            # We have more than one cmdset in stack; list them all
            for snum, cmdset in enumerate(self.cmdset_stack):
                mergelist.append(str(snum + 1))
                strings.append(f" {snum + 1}: {cmdset}")

        # Display the currently active cmdset, limited by self.obj's permissions
        mergetype = self.mergetype_stack[-1]
        if mergetype != self.current.mergetype:
            merged_on = self.cmdset_stack[-2].key
            mergetype = _("custom {mergetype} on cmdset '{cmdset}'")
            mergetype = mergetype.format(mergetype=mergetype, cmdset=merged_on)

        if mergelist:
            # current is a result of mergers
            mergelist = "+".join(mergelist)
            strings.append(f" <Merged {mergelist}>: {self.current}")
        else:
            # current is a single cmdset
            strings.append(" " + str(self.current))
        return "\n".join(strings).rstrip()

    def _import_cmdset(self, cmdset_path, emit_to_obj=None):
        """
        Method wrapper for import_cmdset; Loads a cmdset from a
        module.

        Args:
            cmdset_path (str): The python path to an cmdset object.
            emit_to_obj (Object): The object to send error messages to

        Returns:
            cmdset (Cmdset): The imported cmdset.

        """
        if not emit_to_obj:
            emit_to_obj = self.obj
        return import_cmdset(cmdset_path, self.obj, emit_to_obj)

    def update(self, init_mode=False):
        """
        Re-adds all sets in the handler to have an updated current

        Args:
            init_mode (bool, optional): Used automatically right after
                this handler was created; it imports all persistent cmdsets
                from the database.

        Notes:
            This method is necessary in order to always have a `.current`
            cmdset when working with the cmdsethandler in code. But the
            CmdSetHandler doesn't (cannot) consider external cmdsets and game
            state. This means that the .current calculated from this method
            will likely not match the true current cmdset as determined at
            run-time by `cmdhandler.get_and_merge_cmdsets()`. So in a running
            game the responsibility of keeping `.current` upt-to-date belongs
            to the central `cmdhandler.get_and_merge_cmdsets()`!

        """
        if init_mode:
            # reimport all persistent cmdsets
            storage = self.obj.cmdset_storage
            if storage:
                self.cmdset_stack = []
                for pos, path in enumerate(storage):
                    if pos == 0 and not path:
                        self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)]
                    elif path:
                        cmdset = self._import_cmdset(path)
                        if cmdset:
                            if cmdset.key == "_CMDSET_ERROR":
                                # If a cmdset fails to load, check if we have a fallback path to use
                                fallback_path = _CMDSET_FALLBACKS.get(path, None)
                                if fallback_path:
                                    err = _ERROR_CMDSET_FALLBACK.format(
                                        path=path, fallback_path=fallback_path
                                    )
                                    logger.log_err(err)
                                    if _IN_GAME_ERRORS:
                                        self.obj.msg(err)
                                    cmdset = self._import_cmdset(fallback_path)
                                # If no cmdset is returned from the fallback, we can't go further
                                if not cmdset:
                                    err = _ERROR_CMDSET_NO_FALLBACK.format(
                                        fallback_path=fallback_path
                                    )
                                    logger.log_err(err)
                                    if _IN_GAME_ERRORS:
                                        self.obj.msg(err)
                                    continue
                            cmdset.persistent = cmdset.key != "_CMDSET_ERROR"
                            self.cmdset_stack.append(cmdset)

        # merge the stack into a new merged cmdset
        new_current = None
        self.mergetype_stack = []
        for cmdset in self.cmdset_stack:
            try:
                # for cmdset's '+' operator, order matters.
                new_current = cmdset + new_current
            except TypeError:
                continue
            self.mergetype_stack.append(new_current.actual_mergetype)
        self.current = new_current

    def add(self, cmdset, emit_to_obj=None, persistent=False, default_cmdset=False, **kwargs):
        """
        Add a cmdset to the handler, on top of the old ones, unless it
        is set as the default one (it will then end up at the bottom of the stack)

        Args:
            cmdset (CmdSet or str): Can be a cmdset object or the python path
                to such an object.
            emit_to_obj (Object, optional): An object to receive error messages.
            persistent (bool, optional): Let cmdset remain across server reload.
            default_cmdset (Cmdset, optional): Insert this to replace the
                default cmdset position (there is only one such position,
                always at the bottom of the stack).

        Notes:
          An interesting feature of this method is if you were to send
          it an *already instantiated cmdset* (i.e. not a class), the
          current cmdsethandler's obj attribute will then *not* be
          transferred over to this already instantiated set (this is
          because it might be used elsewhere and can cause strange
          effects).  This means you could in principle have the
          handler launch command sets tied to a *different* object
          than the handler. Not sure when this would be useful, but
          it's a 'quirk' that has to be documented.

        """
        if "permanent" in kwargs:
            logger.log_dep("obj.cmdset.add() kwarg 'permanent' has changed name to 'persistent'.")
            persistent = kwargs["permanent"] if persistent is False else persistent

        if not (isinstance(cmdset, str) or utils.inherits_from(cmdset, CmdSet)):
            string = _("Only CmdSets can be added to the cmdsethandler!")
            raise Exception(string)

        if callable(cmdset):
            cmdset = cmdset(self.obj)
        elif isinstance(cmdset, str):
            # this is (maybe) a python path. Try to import from cache.
            cmdset = self._import_cmdset(cmdset)
        if cmdset and cmdset.key != "_CMDSET_ERROR":
            cmdset.persistent = persistent
            if persistent and cmdset.key != "_CMDSET_ERROR":
                # store the path permanently
                storage = self.obj.cmdset_storage or [""]
                if default_cmdset:
                    storage[0] = cmdset.path
                else:
                    storage.append(cmdset.path)
                self.obj.cmdset_storage = storage
            if default_cmdset:
                self.cmdset_stack[0] = cmdset
            else:
                self.cmdset_stack.append(cmdset)
            self.update()

    def add_default(self, cmdset, emit_to_obj=None, persistent=True, **kwargs):
        """
        Shortcut for adding a default cmdset.

        Args:
            cmdset (Cmdset): The Cmdset to add.
            emit_to_obj (Object, optional): Gets error messages
            persistent (bool, optional): The new Cmdset should survive a server reboot.

        """
        if "permanent" in kwargs:
            logger.log_dep(
                "obj.cmdset.add_default() kwarg 'permanent' has changed name to 'persistent'."
            )
            persistent = kwargs["permanent"] if persistent is None else persistent
        self.add(cmdset, emit_to_obj=emit_to_obj, persistent=persistent, default_cmdset=True)

    def remove(self, cmdset=None, default_cmdset=False):
        """
        Remove a cmdset from the  handler.

        Args:
            cmdset (CommandSet or str, optional): This can can be supplied either as a cmdset-key,
                an instance of the CmdSet or a python path to the cmdset.
                If no key is given, the last cmdset in the stack is
                removed. Whenever the cmdset_stack changes, the cmdset is
                updated. If default_cmdset is set, this argument is ignored.
            default_cmdset (bool, optional): If set, this will remove the
                default cmdset (at the bottom of the stack).

        """
        if default_cmdset:
            # remove the default cmdset only
            if self.cmdset_stack:
                cmdset = self.cmdset_stack[0]
                if cmdset.persistent:
                    storage = self.obj.cmdset_storage or [""]
                    storage[0] = ""
                    self.obj.cmdset_storage = storage
                self.cmdset_stack[0] = _EmptyCmdSet(cmdsetobj=self.obj)
            else:
                self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)]
            self.update()
            return

        if len(self.cmdset_stack) < 2:
            # don't allow deleting default cmdsets here.
            return

        if not cmdset:
            # remove the last one in the stack
            cmdset = self.cmdset_stack.pop()
            if cmdset.persistent:
                storage = self.obj.cmdset_storage
                storage.pop()
                self.obj.cmdset_storage = storage
        else:
            # try it as a callable
            if callable(cmdset) and hasattr(cmdset, "path"):
                delcmdsets = [cset for cset in self.cmdset_stack[1:] if cset.path == cmdset.path]
            else:
                # try it as a path or key
                delcmdsets = [
                    cset
                    for cset in self.cmdset_stack[1:]
                    if cset.path == cmdset or cset.key == cmdset
                ]
            storage = []

            if any(cset.persistent for cset in delcmdsets):
                # only hit database if there's need to
                storage = self.obj.cmdset_storage
                updated = False
                for cset in delcmdsets:
                    if cset.persistent:
                        try:
                            storage.remove(cset.path)
                            updated = True
                        except ValueError:
                            # nothing to remove
                            pass
                if updated:
                    self.obj.cmdset_storage = storage
            for cset in delcmdsets:
                # clean the in-memory stack
                try:
                    self.cmdset_stack.remove(cset)
                except ValueError:
                    # nothing to remove
                    pass
        # re-sync the cmdsethandler.
        self.update()

    # legacy alias
    delete = remove

    def remove_default(self):
        """
        This explicitly deletes only the default cmdset.

        """
        self.remove(default_cmdset=True)

    # legacy alias
    delete_default = remove_default

    def get(self):
        """
        Get all cmdsets.

        Returns:
            cmdsets (list): All the command sets currently in the handler.

        """
        return self.cmdset_stack

    # backwards-compatible alias
    all = get

    def clear(self):
        """
        Removes all Command Sets from the handler except the default one
        (use `self.remove_default` to remove that).

        """
        self.cmdset_stack = [self.cmdset_stack[0]]
        storage = self.obj.cmdset_storage
        if storage:
            storage = storage[0]
            self.obj.cmdset_storage = storage
        self.update()

    def has(self, cmdset, must_be_default=False):
        """
        checks so the cmdsethandler contains a given cmdset

        Args:
            cmdset (str or Cmdset): Cmdset key, pythonpath or
                Cmdset to check the existence for.
            must_be_default (bool, optional): Only return True if
                the checked cmdset is the default one.

        Returns:
            has_cmdset (bool): Whether or not the cmdset is in the handler.

        """
        if callable(cmdset) and hasattr(cmdset, "path"):
            # try it as a callable
            if must_be_default:
                return self.cmdset_stack and (self.cmdset_stack[0].path == cmdset.path)
            else:
                return any([cset for cset in self.cmdset_stack if cset.path == cmdset.path])
        else:
            # try it as a path or key
            if must_be_default:
                return self.cmdset_stack and (
                    self.cmdset_stack[0].key == cmdset or self.cmdset_stack[0].path == cmdset
                )
            else:
                return any(
                    [
                        cset
                        for cset in self.cmdset_stack
                        if cset.path == cmdset or cset.key == cmdset
                    ]
                )

    # backwards-compatability alias
    has_cmdset = has

    def reset(self):
        """
        Force reload of all cmdsets in handler. This should be called
        after _CACHED_CMDSETS have been cleared (normally this is
        handled automatically by @reload).

        """
        new_cmdset_stack = []
        for cmdset in self.cmdset_stack:
            if cmdset.key == "_EMPTY_CMDSET":
                new_cmdset_stack.append(cmdset)
            else:
                new_cmdset_stack.append(self._import_cmdset(cmdset.path))
        self.cmdset_stack = new_cmdset_stack
        self.update()
