# coding=utf-8
#
# Copyright (C) 2018-2019 Martin Owens
#               2019 Thomas Holder
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110, USA.
#
"""
Testing module. See :ref:`unittests` for details.
"""

import os
import re
import sys
import shutil
import tempfile
import hashlib
import random
import uuid
import io
from typing import List, Union, Tuple, Type, TYPE_CHECKING

from io import BytesIO, StringIO
import xml.etree.ElementTree as xml

from unittest import TestCase as BaseCase
from inkex.base import InkscapeExtension
from inkex.extensions import OutputExtension

from .. import Transform, load_svg, SvgDocumentElement
from ..utils import to_bytes
from .xmldiff import xmldiff
from .mock import MockCommandMixin, Capture

if TYPE_CHECKING:
    from .filters import Compare

COMPARE_DELETE, COMPARE_CHECK, COMPARE_WRITE, COMPARE_OVERWRITE = range(4)


class NoExtension(InkscapeExtension):  # pylint: disable=too-few-public-methods
    """Test case must specify 'self.effect_class' to assertEffect."""

    def __init__(self, *args, **kwargs):  # pylint: disable=super-init-not-called
        raise NotImplementedError(self.__doc__)

    def run(self, args=None, output=None):
        """Fake run"""


class TestCase(MockCommandMixin, BaseCase):
    """
    Base class for all effects tests, provides access to data_files and
    test_without_parameters
    """

    effect_class = NoExtension  # type: Type[InkscapeExtension]
    effect_name = property(lambda self: self.effect_class.__module__)

    # If set to true, the output is not expected to be the stdout SVG document, but
    # rather text or a message sent to the stderr, this is highly weird. But sometimes
    # happens.
    stderr_output = False
    stdout_protect = True
    stderr_protect = True

    def __init__(self, *args, **kw):
        super().__init__(*args, **kw)
        self._temp_dir = None
        self._effect = None

    def setUp(self):  # pylint: disable=invalid-name
        """Make sure every test is seeded the same way"""
        self._effect = None
        super().setUp()
        random.seed(0x35F)

    def tearDown(self):
        super().tearDown()
        if self._temp_dir and os.path.isdir(self._temp_dir):
            shutil.rmtree(self._temp_dir)

    @classmethod
    def __file__(cls):
        """Create a __file__ property which acts much like the module version"""
        return os.path.abspath(sys.modules[cls.__module__].__file__)

    @classmethod
    def _testdir(cls):
        """Get's the folder where the test exists (so data can be found)"""
        return os.path.dirname(cls.__file__())

    @classmethod
    def rootdir(cls):
        """Return the full path to the extensions directory"""
        return os.path.dirname(cls._testdir())

    @classmethod
    def datadir(cls):
        """Get the data directory (can be over-ridden if needed)"""
        return os.path.join(cls._testdir(), "data")

    @property
    def tempdir(self):
        """Generate a temporary location to store files"""
        if self._temp_dir is None:
            self._temp_dir = os.path.realpath(tempfile.mkdtemp(prefix="inkex-tests-"))
        if not os.path.isdir(self._temp_dir):
            raise IOError("The temporary directory has disappeared!")
        return self._temp_dir

    def temp_file(
        self, prefix="file-", template="{prefix}{name}{suffix}", suffix=".tmp"
    ):
        """Generate the filename of a temporary file"""
        filename = template.format(prefix=prefix, suffix=suffix, name=uuid.uuid4().hex)
        return os.path.join(self.tempdir, filename)

    @classmethod
    def data_file(cls, filename, *parts, check_exists=True):
        """Provide a data file from a filename, can accept directories as arguments.

        .. versionchanged:: 1.2
            ``check_exists`` parameter added
        """
        if os.path.isabs(filename):
            # Absolute root was passed in, so we trust that (it might be a tempdir)
            full_path = os.path.join(filename, *parts)
        else:
            # Otherwise we assume it's relative to the test data dir.
            full_path = os.path.join(cls.datadir(), filename, *parts)

        if not os.path.isfile(full_path) and check_exists:
            raise IOError(f"Can't find test data file: {full_path}")
        return full_path

    @property
    def empty_svg(self):
        """Returns a common minimal svg file"""
        return self.data_file("svg", "default-inkscape-SVG.svg")

    def assertAlmostTuple(self, found, expected, precision=8, msg=""):  # pylint: disable=invalid-name
        """
        Floating point results may vary with computer architecture; use
        assertAlmostEqual to allow a tolerance in the result.
        """
        self.assertEqual(len(found), len(expected), msg)
        for fon, exp in zip(found, expected):
            self.assertAlmostEqual(fon, exp, precision, msg)

    def assertEffectEmpty(self, effect, **kwargs):  # pylint: disable=invalid-name
        """Assert calling effect without any arguments"""
        self.assertEffect(effect=effect, **kwargs)

    def assertEffect(self, *filename, **kwargs):  # pylint: disable=invalid-name
        """Assert an effect, capturing the output to stdout.

        :py:attr:`filename` should point to a starting svg document, default is empty_svg
        """
        if filename:
            data_file = self.data_file(*filename)
        else:
            data_file = self.empty_svg

        os.environ["DOCUMENT_PATH"] = data_file
        args = [data_file] + list(kwargs.pop("args", []))
        args += [f"--{kw[0]}={kw[1]}" for kw in kwargs.items()]

        effect = kwargs.pop("effect", self.effect_class)()

        # Output is redirected to this string io buffer
        if self.stderr_output:
            with Capture("stderr") as stderr:
                effect.run(args, output=BytesIO())
                effect.test_output = stderr
        else:
            output = BytesIO()
            with Capture(
                "stdout", kwargs.get("stdout_protect", self.stdout_protect)
            ) as stdout:
                with Capture(
                    "stderr", kwargs.get("stderr_protect", self.stderr_protect)
                ) as stderr:
                    effect.run(args, output=output)
                    self.assertEqual(
                        "", stdout.getvalue(), "Extra print statements detected"
                    )
                    self.assertEqual(
                        "", stderr.getvalue(), "Extra error or warnings detected"
                    )
            effect.test_output = output

        if os.environ.get("FAIL_ON_DEPRECATION", False):
            warnings = getattr(effect, "warned_about", set())
            effect.warned_about = set()  # reset for next test
            self.assertFalse(warnings, "Deprecated API is still being used!")

        return effect

    # pylint: disable=invalid-name
    def assertDeepAlmostEqual(self, first, second, places=None, msg=None, delta=None):
        """Asserts that two objects, possible nested lists, are almost equal."""
        if delta is None and places is None:
            places = 7
        if isinstance(first, (list, tuple)):
            assert len(first) == len(second)
            for f, s in zip(first, second):
                self.assertDeepAlmostEqual(f, s, places, msg, delta)
        else:
            self.assertAlmostEqual(first, second, places, msg, delta)

    def assertTransformEqual(self, lhs, rhs, places=7):
        """Assert that two transform expressions evaluate to the same
        transformation matrix.

        .. versionadded:: 1.1
        """
        self.assertAlmostTuple(
            tuple(Transform(lhs).to_hexad()), tuple(Transform(rhs).to_hexad()), places
        )

    # pylint: enable=invalid-name

    @property
    def effect(self):
        """Generate an effect object"""
        if self._effect is None:
            self._effect = self.effect_class()
        return self._effect

    def import_string(self, string, *args) -> SvgDocumentElement:
        """Runs a string through an import extension, with optional arguments
        provided as "--arg=value" arguments"""
        stream = io.BytesIO(string.encode())
        reader = self.effect_class()
        out = io.BytesIO()
        reader.parse_arguments([*args])
        reader.options.input_file = stream
        reader.options.output = out
        reader.load_raw()
        reader.save_raw(reader.effect())
        out.seek(0)
        decoded = out.read().decode("utf-8")
        document = load_svg(decoded)
        return document

    def export_svg(self, document, *args) -> str:
        """Runs a svg through an export extension, with optional arguments
        provided as "--arg=value" arguments"""
        assert isinstance(self, OutputExtension)
        output = StringIO()
        writer = self.effect_class()
        writer.parse_arguments([*args])
        writer.svg = document.getroot()
        writer.document = document
        writer.effect()
        writer.save(output)
        output.seek(0)
        return output.read()


class InkscapeExtensionTestMixin:
    """Automatically setup self.effect for each test and test with an empty svg"""

    def setUp(self):  # pylint: disable=invalid-name
        """Check if there is an effect_class set and create self.effect if it is"""
        super().setUp()
        if self.effect_class is None:
            self.skipTest("self.effect_class is not defined for this this test")

    def test_default_settings(self):
        """Extension works with empty svg file"""
        self.effect.run([self.empty_svg])


class ComparisonMeta(type):
    """Metaclass for ComparisonMixin which creates parametrized tests that can be run
    independently. See :class:`~inkex.tester.ComparisonMixin` for details.

    ..versionadded :: 1.4
    """

    def __init__(cls, name, bases, attrs):
        super().__init__(name, bases, attrs)
        if name == "ComparisonMixin":
            return  # don't execute on the base class
        _compare_file = cls.compare_file
        _comparisons = cls.comparisons
        if hasattr(cls, "comparisons_cmpfile_dict"):
            _comparisons = cls.comparisons_cmpfile_dict.keys()

            def get_compare_cmpfile(self, args, addout=None):
                return self.data_file("refs", cls.comparisons_cmpfile_dict[args])

            setattr(cls, "get_compare_cmpfile", get_compare_cmpfile)

        test_method_name = f"test_all_comparisons"
        if hasattr(cls, test_method_name):
            return  # Custom test logic, don't touch

        append = isinstance(_compare_file, (list, tuple))
        compare_file = _compare_file if append else [_compare_file]
        try:
            for file in compare_file:
                for comparison in _comparisons:
                    test_method_name = f"test_comparison_{comparison}_{file}"
                    setattr(
                        cls,
                        test_method_name,
                        ComparisonMeta.create_test_method(
                            comparison,
                            file,
                            os.path.basename(file) if append else None,
                        ),
                    )
        except TypeError:
            # If the compare_file or compare_
            test_method_name = f"test_all_comparisons"

            def test_method(self):
                if not isinstance(self.compare_file, (list, tuple)):
                    self._test_comparisons(self.compare_file)
                else:
                    for compare_file in self.compare_file:
                        self._test_comparisons(
                            compare_file, addout=os.path.basename(compare_file)
                        )

            setattr(cls, test_method_name, test_method)

    @staticmethod
    def create_test_method(comparison, file, addout):
        def _test_method(self):
            self._test_comparison(comparison, file, addout)

        return _test_method


class ComparisonMixin(metaclass=ComparisonMeta):
    """
    This mixin allows to easily specify a set of run-through unit tests for an
    extension, which is specified in :attr:`inkex.tester.TestCase.effect_class`.

    The commandline parameters are passed in :attr:`comparisons`, the input file
    in :attr:`compare_file` (either a list of files, or a single file).

    The :class:`ComparisonMeta` metaclass creates a set of independent unit tests
    out of this data. Behavior notest:

    - The unit tests created are the cross product of :attr:`comparisons` and
      :attr:`compare_file`. If :attr:`compare_file` is a list, the comparison file name
      is suffixed with the current ``compare_file`` name.
    - Optionally, :attr:`comparisons_cmpfile_dict` may be specified as
      ``Dict[Tuple[str], str]`` where the keys are sets of command line parameters and
      the values are the filenames of the output file. This takes precedence over
      :attr:`comparisons`.
    - If any of those values are properties, their values cannot be accessed at test
      collection time and there will only be a single test, ``test_all_comparisons``
      with otherwise identical behavior.
    - If the class overrides ``test_all_comparisons``, no additional tests are
      generated to allow for custom comparison logic.

    To create the comparison files for the unit tests, use the ``EXPORT_COMPARE``
    environment variable.
    """

    compare_file: Union[List[str], Tuple[str], str] = "svg/shapes.svg"
    """This input svg file sent to the extension (if any)"""

    compare_filters = []  # type: List[Compare]
    """The ways in which the output is filtered for comparision (see filters.py)"""

    compare_filter_save = False
    """If true, the filtered output will be saved and only applied to the
    extension output (and not to the reference file)"""

    comparisons = [
        (),
        ("--id=p1", "--id=r3"),
    ]
    """A list of comparison runs, each entry will cause the extension to be run."""

    compare_file_extension = "svg"

    @property
    def _compare_file_extension(self):
        """The default extension to use when outputting check files in COMPARE_CHECK
        mode."""
        if self.stderr_output:
            return "txt"
        return self.compare_file_extension

    def _test_comparisons(self, compare_file, addout=None):
        for args in self.comparisons:
            self._test_comparison(args, compare_file=compare_file, addout=addout)

    def _test_comparison(self, args, compare_file, addout=None):
        self.assertCompare(
            compare_file,
            self.get_compare_cmpfile(args, addout),
            args,
        )

    def assertCompare(self, infile, cmpfile, args, outfile=None):  # pylint: disable=invalid-name
        """
        Compare the output of a previous run against this one.

        Args:
            infile: The filename of the pre-processed svg (or other type of file)
            cmpfile: The filename of the data we expect to get, if not set
                the filename will be generated from the effect name and kwargs.
            args: All the arguments to be passed to the effect run
            outfile: Optional, instead of returning a regular output, this extension
                dumps it's output to this filename instead.

        """
        compare_mode = int(os.environ.get("EXPORT_COMPARE", COMPARE_DELETE))

        effect = self.assertEffect(infile, args=args)

        if cmpfile is None:
            cmpfile = self.get_compare_cmpfile(args)

        if not os.path.isfile(cmpfile) and compare_mode == COMPARE_DELETE:
            raise IOError(
                f"Comparison file {cmpfile} not found, set EXPORT_COMPARE=1 to create "
                "it."
            )

        if outfile:
            if not os.path.isabs(outfile):
                outfile = os.path.join(self.tempdir, outfile)
            self.assertTrue(
                os.path.isfile(outfile), f"No output file created! {outfile}"
            )
            with open(outfile, "rb") as fhl:
                data_a = fhl.read()
        else:
            data_a = effect.test_output.getvalue()

        write_output = None
        if compare_mode == COMPARE_CHECK:
            _file = cmpfile[:-4] if cmpfile.endswith(".out") else cmpfile
            write_output = f"{_file}.{self._compare_file_extension}"
        elif (
            compare_mode == COMPARE_WRITE and not os.path.isfile(cmpfile)
        ) or compare_mode == COMPARE_OVERWRITE:
            write_output = cmpfile

        try:
            if write_output and not os.path.isfile(cmpfile):
                raise AssertionError(f"Check the output: {write_output}")
            with open(cmpfile, "rb") as fhl:
                data_b = self._apply_compare_filters(fhl.read(), False)
            self._base_compare(data_a, data_b, compare_mode)
        except AssertionError:
            if write_output:
                if isinstance(data_a, str):
                    data_a = data_a.encode("utf-8")
                self.write_compare_data(infile, write_output, data_a)
                # This only reruns if the original test failed.
                # The idea here is to make sure the new output file is "stable"
                # Because some tests can produce random changes and we don't
                # want test authors to be too reassured by a simple write.
                if write_output == cmpfile:
                    effect = self.assertEffect(infile, args=args)
                    self._base_compare(data_a, cmpfile, COMPARE_CHECK)
            if not write_output == cmpfile:
                raise

    def write_compare_data(self, infile, outfile, data):
        """Write output"""
        with open(outfile, "wb") as fhl:
            fhl.write(self._apply_compare_filters(data, True))
            print(f"Written output: {outfile}")

    def _base_compare(self, data_a, data_b, compare_mode):
        data_a = self._apply_compare_filters(data_a)

        if (
            isinstance(data_a, bytes)
            and isinstance(data_b, bytes)
            and data_a.startswith(b"<")
            and data_b.startswith(b"<")
        ):
            # Late importing
            diff_xml, delta = xmldiff(data_a, data_b)
            if not delta and compare_mode == COMPARE_DELETE:
                print(
                    "The XML is different, you can save the output using the "
                    "EXPORT_COMPARE envionment variable. Set it to 1 to save a file "
                    "you can check, set it to 3 to overwrite this comparison, setting "
                    "the new data as the correct one.\n"
                )
            diff = "SVG Differences\n\n"
            if os.environ.get("XML_DIFF", False):
                diff = "<- " + diff_xml
            else:
                for x, (value_a, value_b) in enumerate(delta):
                    try:
                        # Take advantage of better text diff in testcase's own asserts.
                        self.assertEqual(value_a, value_b)
                    except AssertionError as err:
                        diff += f" {x}. {str(err)}\n"
            self.assertTrue(delta, diff)
        else:
            # compare any content (non svg)
            self.assertEqual(data_a, data_b)

    def _apply_compare_filters(self, data, is_saving=None):
        data = to_bytes(data)
        # Applying filters flips depending if we are saving the filtered content
        # to disk, or filtering during the test run. This is because some filters
        # are destructive others are useful for diagnostics.
        if is_saving is self.compare_filter_save or is_saving is None:
            for cfilter in self.compare_filters:
                data = cfilter(data)
        return data

    def get_compare_cmpfile(self, args, addout=None):
        """Generate an output file for the arguments given"""
        if addout is not None:
            args = list(args) + [str(addout)]
        opstr = (
            "__".join(args)
            .replace(self.tempdir, "TMP_DIR")
            .replace(self.datadir(), "DAT_DIR")
        )
        opstr = re.sub(r"[^\w-]", "__", opstr)
        if opstr:
            if len(opstr) > 127:
                # avoid filename-too-long error
                opstr = hashlib.md5(opstr.encode("latin1")).hexdigest()
            opstr = "__" + opstr
        return self.data_file(
            "refs", f"{self.effect_name}{opstr}.out", check_exists=False
        )
