"""
Various helper resources for writing unittests.

Classes for testing Evennia core:

- `BaseEvenniaTestCase` - no default objects, only enforced default settings
- `BaseEvenniaTest` - all default objects, enforced default settings
- `BaseEvenniaCommandTest` - for testing Commands, enforced default settings

Classes for testing game folder content:

- `EvenniaTestCase` - no default objects, using gamedir settings (identical to
   standard Python TestCase)
- `EvenniaTest` - all default objects, using gamedir settings
- `EvenniaCommandTest` - for testing game folder commands, using gamedir settings

Other:

- `EvenniaTestMixin` - A class mixin for creating the test environment objects, for
  making custom tests.
- `EvenniaCommandMixin` - A class mixin that adds support for command testing with the .call()
  helper. Used by the command-test classes, but can be used for making a customt test class.

"""

import re
import sys
import types

from django.conf import settings
from django.test import TestCase, override_settings
from mock import MagicMock, Mock, patch
from twisted.internet.defer import Deferred

import evennia
from evennia import settings_default
from evennia.accounts.accounts import DefaultAccount
from evennia.commands.command import InterruptCommand
from evennia.commands.default.muxcommand import MuxCommand
from evennia.objects.objects import (
    DefaultCharacter,
    DefaultExit,
    DefaultObject,
    DefaultRoom,
)
from evennia.scripts.scripts import DefaultScript
from evennia.server.serversession import ServerSession
from evennia.utils import ansi, create
from evennia.utils.idmapper.models import flush_cache
from evennia.utils.utils import all_from_module, to_str

_RE_STRIP_EVMENU = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)


# set up a 'pristine' setting, unaffected by any changes in mygame
DEFAULT_SETTING_RESETS = dict(
    CONNECTION_SCREEN_MODULE="evennia.game_template.server.conf.connection_screens",
    AT_SERVER_STARTSTOP_MODULE="evennia.game_template.server.conf.at_server_startstop",
    AT_SERVICES_PLUGINS_MODULES=["evennia.game_template.server.conf.server_services_plugins"],
    PORTAL_SERVICES_PLUGIN_MODULES=["evennia.game_template.server.conf.portal_services_plugins"],
    MSSP_META_MODULE="evennia.game_template.server.conf.mssp",
    WEB_PLUGINS_MODULE="server.conf.web_plugins",
    LOCK_FUNC_MODULES=("evennia.locks.lockfuncs", "evennia.game_template.server.conf.lockfuncs"),
    INPUT_FUNC_MODULES=[
        "evennia.server.inputfuncs",
        "evennia.game_template.server.conf.inputfuncs",
    ],
    PROTOTYPE_MODULES=["evennia.game_template.world.prototypes"],
    CMDSET_UNLOGGEDIN="evennia.game_template.commands.default_cmdsets.UnloggedinCmdSet",
    CMDSET_SESSION="evennia.game_template.commands.default_cmdsets.SessionCmdSet",
    CMDSET_CHARACTER="evennia.game_template.commands.default_cmdsets.CharacterCmdSet",
    CMDSET_ACCOUNT="evennia.game_template.commands.default_cmdsets.AccountCmdSet",
    CMDSET_PATHS=["evennia.game_template.commands", "evennia", "evennia.contrib"],
    TYPECLASS_PATHS=[
        "evennia",
        "evennia.contrib",
        "evennia.contrib.game_systems",
        "evennia.contrib.base_systems",
        "evennia.contrib.full_systems",
        "evennia.contrib.tutorials",
        "evennia.contrib.utils",
    ],
    BASE_ACCOUNT_TYPECLASS="evennia.accounts.accounts.DefaultAccount",
    BASE_OBJECT_TYPECLASS="evennia.objects.objects.DefaultObject",
    BASE_CHARACTER_TYPECLASS="evennia.objects.objects.DefaultCharacter",
    BASE_ROOM_TYPECLASS="evennia.objects.objects.DefaultRoom",
    BASE_EXIT_TYPECLASS="evennia.objects.objects.DefaultExit",
    BASE_CHANNEL_TYPECLASS="evennia.comms.comms.DefaultChannel",
    BASE_SCRIPT_TYPECLASS="evennia.scripts.scripts.DefaultScript",
    BASE_BATCHPROCESS_PATHS=[
        "evennia.game_template.world",
        "evennia.contrib",
        "evennia.contrib.tutorials",
    ],
    FILE_HELP_ENTRY_MODULES=["evennia.game_template.world.help_entries"],
    FUNCPARSER_OUTGOING_MESSAGES_MODULES=[
        "evennia.utils.funcparser",
        "evennia.game_template.server.conf.inlinefuncs",
    ],
    FUNCPARSER_PROTOTYPE_PARSING_MODULES=[
        "evennia.prototypes.protfuncs",
        "evennia.game_template.server.conf.prototypefuncs",
    ],
    BASE_GUEST_TYPECLASS="evennia.accounts.accounts.DefaultGuest",
    # a special setting boolean TEST_ENVIRONMENT is set by the test runner
    # while the test suite is running.
    DEFAULT_HOME="#1",
    TEST_ENVIRONMENT=True,
)

DEFAULT_SETTINGS = {**all_from_module(settings_default), **DEFAULT_SETTING_RESETS}
DEFAULT_SETTINGS.pop("DATABASES")  # we want different dbs tested in CI


# mocking of evennia.utils.utils.delay
def mockdelay(timedelay, callback, *args, **kwargs):
    callback(*args, **kwargs)
    return Deferred()


# mocking of twisted's deferLater
def mockdeferLater(reactor, timedelay, callback, *args, **kwargs):
    callback(*args, **kwargs)
    return Deferred()


def unload_module(module):
    """
    Reset import so one can mock global constants.

    Args:
        module (module, object or str): The module will
            be removed so it will have to be imported again. If given
            an object, the module in which that object sits will be unloaded. A string
            should directly give the module pathname to unload.

    Example:

        ```python
        # (in a test method)
        unload_module(foo)
        with mock.patch("foo.GLOBALTHING", "mockval"):
            import foo
            ... # test code using foo.GLOBALTHING, now set to 'mockval'
        ```

    This allows for mocking constants global to the module, since
    otherwise those would not be mocked (since a module is only
    loaded once).

    """
    if isinstance(module, str):
        modulename = module
    elif hasattr(module, "__module__"):
        modulename = module.__module__
    else:
        modulename = module.__name__

    if modulename in sys.modules:
        del sys.modules[modulename]


def _mock_deferlater(reactor, timedelay, callback, *args, **kwargs):
    callback(*args, **kwargs)
    return Deferred()


class EvenniaTestMixin:
    """
    Evennia test environment mixin
    """

    account_typeclass = DefaultAccount
    object_typeclass = DefaultObject
    character_typeclass = DefaultCharacter
    exit_typeclass = DefaultExit
    room_typeclass = DefaultRoom
    script_typeclass = DefaultScript

    def create_accounts(self):
        self.account = create.create_account(
            "TestAccount",
            email="test@test.com",
            password="testpassword",
            typeclass=self.account_typeclass,
        )
        self.account2 = create.create_account(
            "TestAccount2",
            email="test@test.com",
            password="testpassword",
            typeclass=self.account_typeclass,
        )
        self.account.permissions.add("Developer")

    def teardown_accounts(self):
        if hasattr(self, "account"):
            self.account.delete()
        if hasattr(self, "account2"):
            self.account2.delete()

    # Set up fake prototype module for allowing tests to use named prototypes.
    @override_settings(PROTOTYPE_MODULES=["evennia.utils.tests.data.prototypes_example"])
    def create_rooms(self):
        self.room1 = create.create_object(self.room_typeclass, key="Room", nohome=True)
        self.room1.db.desc = "room_desc"

        self.room2 = create.create_object(self.room_typeclass, key="Room2")
        self.exit = create.create_object(
            self.exit_typeclass, key="out", location=self.room1, destination=self.room2
        )

    def create_objs(self):
        self.obj1 = create.create_object(
            self.object_typeclass, key="Obj", location=self.room1, home=self.room1
        )
        self.obj2 = create.create_object(
            self.object_typeclass, key="Obj2", location=self.room1, home=self.room1
        )

    def create_chars(self):
        self.char1 = create.create_object(
            self.character_typeclass, key="Char", location=self.room1, home=self.room1
        )
        self.char1.permissions.add("Developer")
        self.char2 = create.create_object(
            self.character_typeclass, key="Char2", location=self.room1, home=self.room1
        )
        self.char1.account = self.account
        self.account.db._last_puppet = self.char1
        self.char2.account = self.account2
        self.account2.db._last_puppet = self.char2

    def create_script(self):
        self.script = create.create_script(self.script_typeclass, key="Script")

    def setup_session(self):
        dummysession = ServerSession()
        dummysession.init_session("telnet", ("localhost", "testmode"), evennia.SESSION_HANDLER)
        dummysession.sessid = 1
        evennia.SESSION_HANDLER.portal_connect(
            dummysession.get_sync_data()
        )  # note that this creates a new Session!
        session = evennia.SESSION_HANDLER.session_from_sessid(1)  # the real session
        evennia.SESSION_HANDLER.login(session, self.account, testmode=True)
        self.session = session

    def teardown_session(self):
        if hasattr(self, "sessions"):
            del evennia.SESSION_HANDLER[self.session.sessid]

    @patch("evennia.scripts.taskhandler.deferLater", _mock_deferlater)
    def setUp(self):
        """
        Sets up testing environment
        """
        self.backups = (
            evennia.SESSION_HANDLER.data_out,
            evennia.SESSION_HANDLER.disconnect,
            settings.DEFAULT_HOME,
            settings.PROTOTYPE_MODULES,
        )
        evennia.SESSION_HANDLER.data_out = Mock()
        evennia.SESSION_HANDLER.disconnect = Mock()

        self.create_accounts()
        self.create_rooms()
        self.create_objs()
        self.create_chars()
        self.create_script()
        self.setup_session()

    @override_settings(PROTOTYPE_MODULES=["evennia.utils.tests.data.prototypes_example"])
    def tearDown(self):
        flush_cache()
        try:
            evennia.SESSION_HANDLER.data_out = self.backups[0]
            evennia.SESSION_HANDLER.disconnect = self.backups[1]
            settings.DEFAULT_HOME = self.backups[2]
            settings.PROTOTYPE_MODULES = self.backups[3]
        except AttributeError as err:
            raise AttributeError(
                f"{err}: Teardown error. If you overrode the `setUp()` method "
                "in your test, make sure you also added `super().setUp()`!"
            )

        del evennia.SESSION_HANDLER[self.session.sessid]
        self.teardown_accounts()
        super().tearDown()


@patch("evennia.server.portal.portal.LoopingCall", new=MagicMock())
class EvenniaCommandTestMixin:
    """
    Mixin to add to a test in order to provide the `.call` helper for
    testing the execution and returns of a command.

    Tests a Command by running it and comparing what messages it sends with
    expected values. This tests without actually spinning up the cmdhandler
    for every test, which is more controlled.

    Example:
    ::

        from commands.echo import CmdEcho

        class MyCommandTest(EvenniaTest, CommandTestMixin):

            def test_echo(self):
                '''
                Test that the echo command really returns
                what you pass into it.
                '''
                self.call(MyCommand(), "hello world!",
                          "You hear your echo: 'Hello world!'")

    """

    # formatting for .call's error message
    _ERROR_FORMAT = """
=========================== Wanted message ===================================
{expected_msg}
=========================== Returned message =================================
{returned_msg}
==============================================================================
""".rstrip()

    def call(
        self,
        cmdobj,
        input_args,
        msg=None,
        cmdset=None,
        noansi=True,
        caller=None,
        receiver=None,
        cmdstring=None,
        obj=None,
        inputs=None,
        raw_string=None,
        use_assertequal=False,
    ):
        """
        Test a command by assigning all the needed properties to a cmdobj and
        running the sequence. The resulting `.msg` calls will be mocked and
        the text= calls to them compared to a expected output.

        Args:
            cmdobj (Command): The command object to use.
            input_args (str): This should be the full input the Command should
                see, such as 'look here'. This will become `.args` for the Command
                instance to parse.
            msg (str or dict, optional): This is the expected return value(s)
                returned through `caller.msg(text=...)` calls in the command. If a string, the
                receiver is controlled with the `receiver` kwarg (defaults to `caller`).
                If this is a `dict`, it is a mapping
                `{receiver1: "expected1", receiver2: "expected2",...}` and `receiver` is
                ignored. The message(s) are compared with the actual messages returned
                to the receiver(s) as the Command runs. Each check uses `.startswith`,
                so you can choose to only include the first part of the
                returned message if that's enough to verify a correct result. EvMenu
                decorations (like borders) are stripped and should not be included. This
                should also not include color tags unless `noansi=False`.
                If the command returns texts in multiple separate `.msg`-
                calls to a receiver, separate these with `|` if `noansi=True`
                (default) and `||` if `noansi=False`. If no `msg` is given (`None`),
                then no automatic comparison will be done.
            cmdset (str, optional): If given, make `.cmdset` available on the Command
                instance as it runs. While `.cmdset` is normally available on the
                Command instance by default, this is usually only used by
                commands that explicitly operates/displays cmdsets, like
                `examine`.
            noansi (str, optional): By default the color tags of the `msg` is
                ignored, this makes them significant. If unset, `msg` must contain
                the same color tags as the actual return message.
            caller (Object or Account, optional): By default `self.char1` is used as the
                command-caller (the `.caller` property on the Command). This allows to
                execute with another caller, most commonly an Account.
            receiver (Object or Account, optional): This is the object to receive the
                return messages we want to test. By default this is the same as `caller`
                (which in turn defaults to is `self.char1`). Note that if `msg` is
                a `dict`, this is ignored since the receiver is already specified there.
            cmdstring (str, optional): Normally this is the Command's `key`.
                This allows for tweaking the `.cmdname` property of the
                Command`.  This isb used for commands with multiple aliases,
                where the command explicitly checs which alias was used to
                determine its functionality.
            obj (str, optional): This sets the `.obj` property of the Command - the
                object on which the Command 'sits'. By default this is the same as `caller`.
                This can be used for testing on-object Command interactions.
            inputs (list, optional): A list of strings to pass to functions that pause to
                take input from the user (normally using `@interactive` and
                `ret = yield(question)` or `evmenu.get_input`). Each  element of the
                list will be passed into the command as if the user answered each prompt
                in that order.
            raw_string (str, optional): Normally the `.raw_string` property  is set as
                a combination of your `key/cmdname` and `input_args`. This allows
                direct control of what this is, for example for testing edge cases
                or malformed inputs.
            use_assertequal (bool, optional): If `True`, the error message will use
                a regular assertEqual. This will show show whitepace differences easier, but
                doesn't allow for only matching against the start of the returned message.

        Returns:
            str or dict: The message sent to `receiver`, or a dict of
                `{receiver: "msg", ...}` if multiple are given. This is usually
                only used with `msg=None` to do the validation externally.

        Raises:
            AssertionError: If the returns of `.msg` calls (tested with `.startswith`) does not
                match `expected_input`.

        Notes:
            As part of the tests, all methods of the Command will be called in
            the proper order:

            - cmdobj.at_pre_cmd()
            - cmdobj.parse()
            - cmdobj.func()
            - cmdobj.at_post_cmd()

        """
        # The `self.char1` is created in the `EvenniaTest` base along with
        # other helper objects like self.room and self.obj
        caller = caller if caller else self.char1
        cmdobj.caller = caller
        cmdobj.cmdname = cmdstring if cmdstring else cmdobj.key
        cmdobj.raw_cmdname = cmdobj.cmdname
        cmdobj.cmdstring = cmdobj.cmdname  # deprecated
        cmdobj.args = input_args
        cmdobj.cmdset = cmdset
        cmdobj.session = evennia.SESSION_HANDLER.session_from_sessid(1)
        cmdobj.account = self.account
        cmdobj.raw_string = raw_string if raw_string is not None else cmdobj.key + " " + input_args
        cmdobj.obj = obj or (caller if caller else self.char1)
        inputs = inputs or []
        # set up receivers
        receiver_mapping = {}
        if isinstance(msg, dict):
            # a mapping {receiver: msg, ...}
            receiver_mapping = {
                recv: str(msg).strip() if msg else None for recv, msg in msg.items()
            }
        else:
            # a single expected string and thus a single receiver (defaults to caller)
            receiver = receiver if receiver else caller
            receiver_mapping[receiver] = str(msg).strip() if msg is not None else None

        unmocked_msg_methods = {}
        for receiver in receiver_mapping:
            # save the old .msg method so we can get it back
            # cleanly  after the test
            unmocked_msg_methods[receiver] = receiver.msg
            # replace normal `.msg` with a mock
            receiver.msg = Mock()

        # Run the methods of the Command. This mimics what happens in the
        # cmdhandler. This will have the mocked .msg be called as part of the
        # execution. Mocks remembers what was sent to them so we will be able
        # to retrieve what was sent later.
        if not cmdobj.at_pre_cmd():
            try:
                cmdobj.parse()
                ret = cmdobj.func()
                # handle func's with yield in them (making them generators)
                if isinstance(ret, types.GeneratorType):
                    while True:
                        try:
                            inp = inputs.pop() if inputs else None
                            if inp:
                                try:
                                    # this mimics a user's reply to a prompt
                                    ret.send(inp)
                                except TypeError:
                                    next(ret)
                                    ret = ret.send(inp)
                            else:
                                # non-input yield, like yield(10). We don't pause
                                # but fire it immediately.
                                next(ret)
                        except StopIteration:
                            break

                cmdobj.at_post_cmd()
            except StopIteration:
                pass
            except InterruptCommand:
                pass

            for inp in inputs:
                # if there are any inputs left, we may have a non-generator
                # input to handle (get_input/ask_yes_no that uses a separate
                # cmdset rather than a yield
                caller.execute_cmd(inp)

        # At this point the mocked .msg methods on each receiver will have
        # stored all calls made to them (that's a basic function of the Mock
        # class). We will not extract them and compare to what we expected to
        # go to each receiver.

        returned_msgs = {}
        for receiver, expected_msg in receiver_mapping.items():
            # get the stored messages from the Mock with Mock.mock_calls.
            stored_msg = [
                args[0] if args and args[0] else kwargs.get("text", to_str(kwargs))
                for name, args, kwargs in receiver.msg.mock_calls
            ]
            # we can return this now, we are done using the mock
            receiver.msg = unmocked_msg_methods[receiver]

            # Get the first element of a tuple if msg received a tuple instead of a string
            stored_msg = [
                str(smsg[0]) if isinstance(smsg, tuple) else str(smsg) for smsg in stored_msg
            ]
            if expected_msg is None:
                # no expected_msg; just build the returned_msgs dict

                returned_msg = "\n".join(str(msg) for msg in stored_msg)
                returned_msgs[receiver] = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
            else:
                # compare messages to expected

                # set our separator for returned messages based on parsing ansi or not
                msg_sep = "|" if noansi else "||"

                # We remove Evmenu decorations since that just makes it harder
                # to write the comparison string. We also strip ansi before this
                # comparison since otherwise it would mess with the regex.
                returned_msg = msg_sep.join(
                    _RE_STRIP_EVMENU.sub("", ansi.parse_ansi(mess, strip_ansi=noansi))
                    for mess in stored_msg
                ).strip()

                # this is the actual test

                if use_assertequal:
                    # regular django assert shows whitespace differences better
                    self.assertEqual(returned_msg, expected_msg)

                if expected_msg == "" and returned_msg or not returned_msg.startswith(expected_msg):
                    # failed the test
                    raise AssertionError(
                        self._ERROR_FORMAT.format(
                            expected_msg=expected_msg, returned_msg=returned_msg
                        )
                    )
                # passed!
                returned_msgs[receiver] = returned_msg

        if len(returned_msgs) == 1:
            return list(returned_msgs.values())[0]
        return returned_msgs


# Base testing classes


@override_settings(**DEFAULT_SETTINGS)
class BaseEvenniaTestCase(TestCase):
    """
    Base test (with no default objects) but with enforced default settings.

    """

    def tearDown(self) -> None:
        super().tearDown()
        flush_cache()


class EvenniaTestCase(TestCase):
    """
    For use with gamedir settings; Just like the normal test case, only for naming consistency.

    Notes:

    -   Inheriting from this class will bypass EvenniaTestMixin, and therefore
        not setup some default objects. This can result in faster tests.

    -   If you do inherit from this class for your unit tests, and have
        overridden the tearDown() method, please also call flush_cache(). Not
        doing so will result in flakey and order-dependent tests due to the
        Django ID cache not being flushed.
    """

    def tearDown(self) -> None:
        super().tearDown()
        flush_cache()


@override_settings(**DEFAULT_SETTINGS)
class BaseEvenniaTest(EvenniaTestMixin, TestCase):
    """
    This class parent has all default objects and uses only default settings.

    """


class EvenniaTest(EvenniaTestMixin, TestCase):
    """
    This test class is intended for inheriting in mygame tests.
    It helps ensure your tests are run with your own objects
    and settings from your game folder.

    """

    account_typeclass = settings.BASE_ACCOUNT_TYPECLASS
    object_typeclass = settings.BASE_OBJECT_TYPECLASS
    character_typeclass = settings.BASE_CHARACTER_TYPECLASS
    exit_typeclass = settings.BASE_EXIT_TYPECLASS
    room_typeclass = settings.BASE_ROOM_TYPECLASS
    script_typeclass = settings.BASE_SCRIPT_TYPECLASS


@patch("evennia.commands.account.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.admin.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.batchprocess.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.building.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.comms.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.general.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.help.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.syscommands.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.system.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.unloggedin.COMMAND_DEFAULT_CLASS", MuxCommand)
@override_settings(**DEFAULT_SETTINGS)
class BaseEvenniaCommandTest(BaseEvenniaTest, EvenniaCommandTestMixin):
    """
    Commands only using the default settings.

    """


class EvenniaCommandTest(EvenniaTest, EvenniaCommandTestMixin):
    """
    Parent class to inherit from - makes tests use your own
    classes and settings in mygame.

    """
