"""Enable the usage of ``django_perf_rec`` on all test methods if enabled in settings

For each test method, the performance recording of database he accesses will be saved in a file
if not already done. Then, on the next run, the recording will be compared to the one saved.
If anything is different, the test will fail.

In case of failure, to see what is different from the previous run, a new perf-rec file is created
at the same location, but suffixed with ``fail`` so differences can be easily spotted. These new
files are excluded from git and packaging and are deleted at when a test starts with perf-rec
activated. They can be deleted at any moment, there are just here to ease the work of the developer.

It is a BAD IDEA to remove the original perf-rec file and rerun the test to recreate it without
checking why this is different. We added this to ensure we don't have regressions in our db
usage (like a ``select_related`` or ``prefetch_related`` missing, for example)

The perf-rec files are saved in a subdirectory of actual tests, named "perf", split in other
directories (by test file and test class), with a perf-rec file (ending with ``.perf.yml``) for
each test method to find them easily.

Note that it's is possible to deactivate perf-rec on a class or a method using the
``with_perf_rec`` and ``without_perf_rec`` decorators. Check the docstring of
``PerfRecMetaclass`` for more information.

It's also possible to skip perf-rec for a whole test run by setting ``settings.USE_PERF_REC`` to
``False`` (this can be done by setting the environment variable ``USE_PERF_REC`` to ``False``)

How to manage errors?
---------------------

When code change in an existing test, or elsewhere, the sql queries that are executed may change.
Let's take an example: the PR https://github.com/magencydigital/mhr/pull/1743
When I run pytest with perf-rec activated, I have many errors.
Let's take one:
    E AssertionError: Performance record did not match for
    mtp/apps/accounts/tests/perf/test_models/MTPUserModelTestCase/test_like.perf.yml

As said above, this failure will create a new perf-rec file suffixed with `.fail`.

So we just have to do a diff to see what's changed. It's easy to get the full path as it's
given in the error. Just copy-paste it twice after `diff` (or whatever tool you like),
removing `.fail` to the last one.
    diff \
    mtp/apps/accounts/tests/perf/test_models/MTPUserModelTestCase/test_like.perf.yml.fail \
    mtp/apps/accounts/tests/perf/test_models/MTPUserModelTestCase/test_like.perf.yml

Here is an extract of the diff:

    < - db: 'SELECT ... FROM "likes_like" WHERE ("likes_like"."content_type_id" = # AND
        "likes_like"."object_uuid" = #::uuid AND "likes_like"."author_id" = #)'
    ---
    > - db: 'SELECT ... FROM "likes_like" WHERE ("likes_like"."content_type_id" = # AND
        "likes_like"."object_uuid" = # AND "likes_like"."author_id" = #)'

We can easily stop the difference and we are ok with this change.
But sometimes something may seems not correct (too many queries added for example),
update the code and run the test(s) again until everything seems fine.

When you're ok with the result, you can replace the perf-rec file with the new one.
For this, just take the previous command and replace `diff` by `mv`

    mv \
    mtp/apps/accounts/tests/perf/test_models/MTPUserModelTestCase/test_like.perf.yml.fail \
    mtp/apps/accounts/tests/perf/test_models/MTPUserModelTestCase/test_like.perf.yml

And now that we have the new perf-rec file, simply add it to git in the same commit as your
code changes.

How to get all the tests failing because of a perf-rec that changed?
--------------------------------------------------------------------

It's as easy as typing this:

    find ./ -name '*.perf.yml.fail'

If you want to delete them all:

    find ./ -name '*.perf.yml.fail' -delete

"""

from contextlib import contextmanager
from functools import partial, wraps
from inspect import getfile, isfunction
from os import makedirs, path, remove

from django.conf import settings


if settings.USE_PERF_REC:

    # django_perc_rec expect pytest > 3 so it calls on the module __init__, at import time,
    # a method not present in pytest < 3
    import pytest
    if not hasattr('pytest', 'register_assert_rewrite'):
        pytest.register_assert_rewrite = lambda *args, **kwargs: None

    # import these only here as they patch django at import-time!
    from django_perf_rec.api import PerformanceRecorder

    class MTPPerformanceRecorder(PerformanceRecorder):
        """Adapt the original PerformanceRecorder to our specific needs

        We don't record cache operations as they not always correctly anonymized.

        And on the final check, we ignore order of sql queries (a lot of tests do
        not run their queries in the same order on each run but they were still the same...)

        Also, when the check fail, we save on disk the record with the same name as the
        original one but suffixed with ``.fail``.
        These "fail" files are set to be ignored by git in our ``.gitignore`` file, and
        are also deleted before the start of a test (to keep only the "fail" files
        created during a pytest run)

        For the parameters, see ``PerformanceRecorder``

        """

        def __init__(self, file_name, record_name):
            """Set the cache recorder to a no-op as we don't want to record cache operations"""

            super().__init__(file_name, record_name)

            self.cache_recorder = self.no_cache_recorder()

        @contextmanager
        def no_cache_recorder(self):
            """A context manager doing nothing to use as a replacement to the cache recorder"""
            yield

        @staticmethod
        def reduce_record(record):
            """Exclude cache entries from the record, and filter+sort sql queries

            The filtering of sql queries is aonly about ones that don't really
            matters and are only about:
            - waffle
            - django content-types

            Parameters
            ----------
            record: list(dict)
                A list of dicts, each dict having only one key, "db" or "cache"

            Returns
            -------
            list
                A sorted list of the sql queries found in the record

            """

            record_entries = [tuple(record_entry.items())[0] for record_entry in record]
            return sorted([
                record_value
                for record_type, record_value
                in record_entries
                if record_type == 'db' \
                    and 'waffle_switch' not in record_value \
                    and 'SELECT ... FROM "django_content_type" WHERE' not in record_value
            ])

        def save_or_assert(self):
            """Override to change the way records are compared and saved failure on disk

            This method doesn't call ``super`` because it is fully reimplemented.

            """

            orig_record = self.records_file.get(self.record_name, None)

            if orig_record is not None:
                try:
                    assert self.reduce_record(self.record) == self.reduce_record(orig_record), \
                        "Performance record did not match for {}".format(self.record_name)
                except AssertionError:
                    self.records_file.file_name += '.fail'
                    self.records_file.set_and_save(self.record_name, self.record)
                    raise
            else:
                self.records_file.set_and_save(self.record_name, self.record)

        def load_recordings(self):
            """Delete the "fail" file for the current record if it exists"""

            try:
                remove(self.file_name + '.fail')
            except OSError:
                pass
            super().load_recordings()

    # Get the parent directory of ``mtp``, used to get the relative path of
    # every perf-rec file. These relative paths will be displayed in case of
    # error to ease the retrieval of failed files.
    MTP_PARENT_DIR = path.normpath(path.join(settings.PROJECT_ROOT, '..'))

    # Have to disable parameters check for pylint that, for any reason, cannot find them
    # in ``__new__``. Also passing this "pylint disable" rule at the method level doesn't
    # work either :(
    # pylint: disable=missing-param-doc,missing-type-doc
    class PerfRecMetaclass(type):
        """Metaclass to use on our base test class when perf-recording is enabled

        All classes inheriting from the one this metaclass is applied to will be updated, with all
        the methods starting with "test_" decorated to activate perf-rec on them.

        If, for any reason, a class should not have this activated by default, simply decorate the
        class with ``@without_perf_rec`` (note that it will also be applied to all subclasses
        that won't be decorated with ``@with_perf_rec``)

        When a class is decorated with ``@without_perf_rec``, if you still want a specific test
        method to  have the perf-rec activated, decorate the method with ``@with_perf_rec``.

        The same way, decorating a test method with ``@without_perf_rec`` will deactivate perf-rec
        for this method, even if perf-rec is enabled on the class.

        """

        @staticmethod
        def decorate_test_method(method, perf_rec_forced):
            """Will decorate the given `method` to enable perf-rec on it

            Parameters
            ----------
            method: function
                The method to decorate. Expected to be a method having ``self`` as first argument.
            perf_rec_forced: bool
                If set to ``True``, perf_rec is enabled for this method even if disabled at the
                class level

            """

            @wraps(method)
            def wrapper(self, *args, **kwargs):  # pylint: disable=missing-param-doc
                """Wrapper around the method to decorate to run perf-rec

                Arguments are the one passed to the method and will be passed to the original
                one unchanged.

                """

                if getattr(self, 'perf_rec_activated', True) or perf_rec_forced:

                    test_file_path = getfile(self.__class__)
                    test_file_name = path.basename(test_file_path)

                    # The perf-rec file for the current test method will be saved in a directory
                    # composed this way:
                    # - the directory holding the test file
                    # - a "perf" subdirectory
                    # - a subdirectory for the test file (with the ending ".py" removed)
                    # - a subdirectory for the test class

                    # For example, the test "test_group_role_created" in the
                    # "GroupRoleModelTestCase" test class in
                    # "mtp/apps/accounts/tests/test_models.py", will have its perf-rec file saved
                    # in "mtp/apps/accounts/tests/perf/test_models/GroupRoleModelTestCase/
                    # and will be named "test_group_role_created.perf.yml"

                    record_dir = path.join(*[
                        path.dirname(test_file_path),
                        'perf',
                        test_file_name[:-3] if test_file_name.endswith('.py') else test_file_name,
                        self.__class__.__name__,
                    ])
                    makedirs(record_dir, exist_ok=True)

                    # Where the perf-rec file will be saved
                    record_path = path.join(record_dir, '%s.perf.yml' % method.__name__)

                    # The name used in the file and displayed in the test output in case of failure
                    # It's the path of the perf-rec file, relative to the mtp base dir,
                    # including 'mtp'
                    record_name = path.relpath(record_path, MTP_PARENT_DIR)

                    with MTPPerformanceRecorder(record_path, record_name):
                        return method(self, *args, **kwargs)

                else:
                    return method(self, *args, **kwargs)

            return wrapper

        def __new__(mcs, name, bases, attrs):
            """Constructor of a class what will decorate test methods to enable test recording

            Only methods starting with "test_" are decorated.

            Parameters
            ----------
            mcs: PerfRecMetaclass
                The current (meta)class
            name: str
                The name of the class actually created
            bases: tuple
                Parent classes from which the  currently created one inherits from
            attrs: dict
                Dictionary holding all the attributes defined on this class

            """

            for attr_name, attr in list(attrs.items()):  # list to allow updating ``attrs``
                # Only work on methods starting with 'test_'
                if not isfunction(attr) or not attr_name.startswith('test_'):
                    continue

                # Decorate the method to activate perf_rec if it was not specifically disabled
                perf_rec_activated = getattr(attr, 'perf_rec_activated', None)
                if perf_rec_activated is not False:
                    attrs[attr_name] = mcs.decorate_test_method(attr, perf_rec_activated is True)

            # Continue the class creation with the updated attributes
            return type.__new__(mcs, name, bases, attrs)

else:
    # if perf recording is not enabled, we create a no-op metaclass

    class PerfRecMetaclass(type):
        """No-op metaclass to use when perf-recording is not enabled"""
        pass


def set_perf_rec(class_or_func, activated):
    """Decorator to activate or deactivate perf-rec on a class or method

    Parameters
    ----------
    class_or_func: callable
        A class or method of a class on which to set the ``perf_rec_activated`` attribute
    activated: bool
        Pass ``True`` to activate perf-rec on the given class or method, and ``False`` to
        deactivate it.

    Returns
    -------
    callable
        The same class or method but with the ``perf_rec_activated`` attribute set to the
        given value

    """

    class_or_func.perf_rec_activated = activated

    return class_or_func

# Define two simpler decorators to activate or not the perf-rec
# pylint: disable=invalid-name
with_perf_rec = partial(set_perf_rec, activated=True)
without_perf_rec = partial(set_perf_rec, activated=False)
