import contextlib
import copy
import logging
import os
import re
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from gitlint.config import LintConfig
from gitlint.deprecation import LOG as DEPRECATION_LOG
from gitlint.deprecation import Deprecation
from gitlint.git import GitChangedFileStats, GitContext
from gitlint.utils import FILE_ENCODING, LOG_FORMAT

EXPECTED_REGEX_STYLE_SEARCH_DEPRECATION_WARNING = (
    "WARNING: gitlint.deprecated.regex_style_search {0} - {1}: gitlint will be switching from using "
    "Python regex 'match' (match beginning) to 'search' (match anywhere) semantics. "
    "Please review your {1}.regex option accordingly. "
    "To remove this warning, set general.regex-style-search=True. More details: "
    "https://jorisroovers.github.io/gitlint/configuration/#regex-style-search"
)


class BaseTestCase(unittest.TestCase):
    """Base class of which all gitlint unit test classes are derived. Provides a number of convenience methods."""

    # In case of assert failures, print the full error message
    maxDiff = None

    # Working directory in which tests in this class are executed
    working_dir = None
    # Originally working dir when the test was started
    original_working_dir = None

    SAMPLES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "samples")
    EXPECTED_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "expected")
    GITLINT_USE_SH_LIB = os.environ.get("GITLINT_USE_SH_LIB", "[NOT SET]")

    @classmethod
    def setUpClass(cls):
        # Run tests a temporary directory to shield them from any local git config
        cls.original_working_dir = os.getcwd()
        cls.working_dir = tempfile.mkdtemp()
        os.chdir(cls.working_dir)

    @classmethod
    def tearDownClass(cls):
        # Go back to original working dir and remove our temp working dir
        os.chdir(cls.original_working_dir)
        shutil.rmtree(cls.working_dir)

    def setUp(self):
        self.logcapture = LogCapture()
        self.logcapture.setFormatter(logging.Formatter(LOG_FORMAT))
        logging.getLogger("gitlint").setLevel(logging.DEBUG)
        logging.getLogger("gitlint").handlers = [self.logcapture]
        DEPRECATION_LOG.handlers = [self.logcapture]

        # Make sure we don't propagate anything to child loggers, we need to do this explicitly here
        # because if you run a specific test file like test_lint.py, we won't be calling the setupLogging() method
        # in gitlint.cli that normally takes care of this
        # Example test where this matters (for DEPRECATION_LOG):
        # gitlint-core/gitlint/tests/rules/test_configuration_rules.py::ConfigurationRuleTests::test_ignore_by_title
        logging.getLogger("gitlint").propagate = False
        DEPRECATION_LOG.propagate = False

        # Make sure Deprecation has a clean config set at the start of each test.
        # Tests that want to specifically test deprecation should override this.
        Deprecation.config = LintConfig()
        # Normally Deprecation only logs messages once per process.
        # For tests we want to log every time, so we reset the warning_msgs set per test.
        Deprecation.warning_msgs = set()

    @staticmethod
    @contextlib.contextmanager
    def tempdir():
        tmpdir = tempfile.mkdtemp()
        try:
            yield tmpdir
        finally:
            shutil.rmtree(tmpdir)

    @staticmethod
    def get_sample_path(filename=""):
        # Don't join up empty files names because this will add a trailing slash
        if filename == "":
            return BaseTestCase.SAMPLES_DIR

        return os.path.join(BaseTestCase.SAMPLES_DIR, filename)

    @staticmethod
    def get_sample(filename=""):
        """Read and return the contents of a file in gitlint/tests/samples"""
        sample_path = BaseTestCase.get_sample_path(filename)
        return Path(sample_path).read_text(encoding=FILE_ENCODING)

    @staticmethod
    def patch_input(side_effect):
        """Patches the built-in input() with a provided side-effect"""
        module_path = "builtins.input"
        patched_module = patch(module_path, side_effect=side_effect)
        return patched_module

    @staticmethod
    def get_expected(filename="", variable_dict=None):
        """Utility method to read an expected file from gitlint/tests/expected and return it as a string.
        Optionally replace template variables specified by variable_dict."""
        expected_path = os.path.join(BaseTestCase.EXPECTED_DIR, filename)
        expected = Path(expected_path).read_text(encoding=FILE_ENCODING)

        if variable_dict:
            expected = expected.format(**variable_dict)
        return expected

    @staticmethod
    def get_user_rules_path():
        return os.path.join(BaseTestCase.SAMPLES_DIR, "user_rules")

    @staticmethod
    def gitcontext(commit_msg_str, changed_files=None):
        """Utility method to easily create gitcontext objects based on a given commit msg string and an optional set of
        changed files"""
        with patch("gitlint.git.git_commentchar") as comment_char:
            comment_char.return_value = "#"
            gitcontext = GitContext.from_commit_msg(commit_msg_str)
            commit = gitcontext.commits[-1]
            if changed_files:
                changed_file_stats = {filename: GitChangedFileStats(filename, 8, 3) for filename in changed_files}
                commit.changed_files_stats = changed_file_stats
            return gitcontext

    @staticmethod
    def gitcommit(commit_msg_str, changed_files=None, **kwargs):
        """Utility method to easily create git commit given a commit msg string and an optional set of changed files"""
        gitcontext = BaseTestCase.gitcontext(commit_msg_str, changed_files)
        commit = gitcontext.commits[-1]
        for attr, value in kwargs.items():
            setattr(commit, attr, value)
        return commit

    def assert_logged(self, expected):
        """Asserts that the logs match an expected string or list.
        This method knows how to compare a passed list of log lines as well as a newline concatenated string
        of all loglines."""
        if isinstance(expected, list):
            self.assertListEqual(self.logcapture.messages, expected)
        else:
            self.assertEqual("\n".join(self.logcapture.messages), expected)

    def assert_log_contains(self, line):
        """Asserts that a certain line is in the logs"""
        self.assertIn(line, self.logcapture.messages)

    def assertRaisesRegex(self, expected_exception, expected_regex, *args, **kwargs):
        """Pass-through method to unittest.TestCase.assertRaisesRegex that applies re.escape() to the passed
        `expected_regex`. This is useful to automatically escape all file paths that might be present in the regex.
        """
        return super().assertRaisesRegex(expected_exception, re.escape(expected_regex), *args, **kwargs)

    def clearlog(self):
        """Clears the log capture"""
        self.logcapture.clear()

    @contextlib.contextmanager
    def assertRaisesMessage(self, expected_exception, expected_msg):
        """Asserts an exception has occurred with a given error message"""
        try:
            yield
        except expected_exception as exc:
            exception_msg = str(exc)
            if exception_msg != expected_msg:  # pragma: nocover
                error = f"Right exception, wrong message:\n      got: {exception_msg}\n expected: {expected_msg}"
                raise self.fail(error) from exc
            # else: everything is fine, just return
            return
        except Exception as exc:  # pragma: nocover
            raise self.fail(f"Expected '{expected_exception.__name__}' got '{exc.__class__.__name__}'") from exc

        # No exception raised while we expected one
        raise self.fail(
            f"Expected to raise {expected_exception.__name__}, didn't get an exception at all"
        )  # pragma: nocover

    def object_equality_test(self, obj, attr_list, ctor_kwargs=None):
        """Helper function to easily implement object equality tests.
        Creates an object clone for every passed attribute and checks for (in)equality
        of the original object with the clone based on those attributes' values.
        This function assumes all attributes in `attr_list` can be passed to the ctor of `obj.__class__`.
        """
        if not ctor_kwargs:
            ctor_kwargs = {}

        attr_kwargs = {}
        for attr in attr_list:
            attr_kwargs[attr] = getattr(obj, attr)

        # For every attr, clone the object and assert the clone and the original object are equal
        # Then, change the current attr and assert objects are unequal
        for attr in attr_list:
            attr_kwargs_copy = copy.deepcopy(attr_kwargs)
            attr_kwargs_copy.update(ctor_kwargs)
            clone = obj.__class__(**attr_kwargs_copy)
            self.assertEqual(obj, clone)

            # Change attribute and assert objects are different (via both attribute set and ctor)
            setattr(clone, attr, "föo")
            self.assertNotEqual(obj, clone)
            attr_kwargs_copy[attr] = "föo"

            self.assertNotEqual(obj, obj.__class__(**attr_kwargs_copy))


class LogCapture(logging.Handler):
    """Mock logging handler used to capture any log messages during tests."""

    def __init__(self, *args, **kwargs):
        logging.Handler.__init__(self, *args, **kwargs)
        self.messages = []

    def emit(self, record):
        self.messages.append(self.format(record))

    def clear(self):
        self.messages = []
