# pylint: disable=inconsistent-return-statements
import copy
import logging
import re

from gitlint.options import IntOption, BoolOption, StrOption, ListOption, RegexOption
from gitlint.exception import GitlintError


class Rule:
    """ Class representing gitlint rules. """
    options_spec = []
    id = None
    name = None
    target = None
    _log = None

    def __init__(self, opts=None):
        if not opts:
            opts = {}
        self.options = {}
        for op_spec in self.options_spec:
            self.options[op_spec.name] = copy.deepcopy(op_spec)
            actual_option = opts.get(op_spec.name)
            if actual_option is not None:
                self.options[op_spec.name].set(actual_option)

    @property
    def log(self):
        if not self._log:
            self._log = logging.getLogger(__name__)
            logging.basicConfig()
        return self._log

    def __eq__(self, other):
        return self.id == other.id and self.name == other.name and \
               self.options == other.options and self.target == other.target  # noqa

    def __str__(self):
        return f"{self.id} {self.name}"  # pragma: no cover


class ConfigurationRule(Rule):
    """ Class representing rules that can dynamically change the configuration of gitlint during runtime. """
    pass


class CommitRule(Rule):
    """ Class representing rules that act on an entire commit at once """
    pass


class LineRule(Rule):
    """ Class representing rules that act on a line by line basis """
    pass


class LineRuleTarget:
    """ Base class for LineRule targets. A LineRuleTarget specifies where a given rule will be applied
    (e.g. commit message title, commit message body).
    Each LineRule MUST have a target specified. """
    pass


class CommitMessageTitle(LineRuleTarget):
    """ Target class used for rules that apply to a commit message title """
    pass


class CommitMessageBody(LineRuleTarget):
    """ Target class used for rules that apply to a commit message body """
    pass


class RuleViolation:
    """ Class representing a violation of a rule. I.e.: When a rule is broken, the rule will instantiate this class
    to indicate how and where the rule was broken. """

    def __init__(self, rule_id, message, content=None, line_nr=None):
        self.rule_id = rule_id
        self.line_nr = line_nr
        self.message = message
        self.content = content

    def __eq__(self, other):
        equal = self.rule_id == other.rule_id and self.message == other.message
        equal = equal and self.content == other.content and self.line_nr == other.line_nr
        return equal

    def __str__(self):
        return f"{self.line_nr}: {self.rule_id} {self.message}: \"{self.content}\""


class UserRuleError(GitlintError):
    """ Error used to indicate that an error occurred while trying to load a user rule """
    pass


class MaxLineLength(LineRule):
    name = "max-line-length"
    id = "R1"
    options_spec = [IntOption('line-length', 80, "Max line length")]
    violation_message = "Line exceeds max length ({0}>{1})"

    def validate(self, line, _commit):
        max_length = self.options['line-length'].value
        if len(line) > max_length:
            return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)]


class TrailingWhiteSpace(LineRule):
    name = "trailing-whitespace"
    id = "R2"
    violation_message = "Line has trailing whitespace"
    pattern = re.compile(r"\s$", re.UNICODE)

    def validate(self, line, _commit):
        if self.pattern.search(line):
            return [RuleViolation(self.id, self.violation_message, line)]


class HardTab(LineRule):
    name = "hard-tab"
    id = "R3"
    violation_message = "Line contains hard tab characters (\\t)"

    def validate(self, line, _commit):
        if "\t" in line:
            return [RuleViolation(self.id, self.violation_message, line)]


class LineMustNotContainWord(LineRule):
    """ Violation if a line contains one of a list of words (NOTE: using a word in the list inside another word is not
    a violation, e.g: WIPING is not a violation if 'WIP' is a word that is not allowed.) """
    name = "line-must-not-contain"
    id = "R5"
    options_spec = [ListOption('words', [], "Comma separated list of words that should not be found")]
    violation_message = "Line contains {0}"

    def validate(self, line, _commit):
        strings = self.options['words'].value
        violations = []
        for string in strings:
            regex = re.compile(rf"\b{string.lower()}\b", re.IGNORECASE | re.UNICODE)
            match = regex.search(line.lower())
            if match:
                violations.append(RuleViolation(self.id, self.violation_message.format(string), line))
        return violations if violations else None


class LeadingWhiteSpace(LineRule):
    name = "leading-whitespace"
    id = "R6"
    violation_message = "Line has leading whitespace"

    def validate(self, line, _commit):
        pattern = re.compile(r"^\s", re.UNICODE)
        if pattern.search(line):
            return [RuleViolation(self.id, self.violation_message, line)]


class TitleMaxLength(MaxLineLength):
    name = "title-max-length"
    id = "T1"
    target = CommitMessageTitle
    options_spec = [IntOption('line-length', 72, "Max line length")]
    violation_message = "Title exceeds max length ({0}>{1})"


class TitleTrailingWhitespace(TrailingWhiteSpace):
    name = "title-trailing-whitespace"
    id = "T2"
    target = CommitMessageTitle
    violation_message = "Title has trailing whitespace"


class TitleTrailingPunctuation(LineRule):
    name = "title-trailing-punctuation"
    id = "T3"
    target = CommitMessageTitle

    def validate(self, title, _commit):
        punctuation_marks = '?:!.,;'
        for punctuation_mark in punctuation_marks:
            if title.endswith(punctuation_mark):
                return [RuleViolation(self.id, f"Title has trailing punctuation ({punctuation_mark})", title)]


class TitleHardTab(HardTab):
    name = "title-hard-tab"
    id = "T4"
    target = CommitMessageTitle
    violation_message = "Title contains hard tab characters (\\t)"


class TitleMustNotContainWord(LineMustNotContainWord):
    name = "title-must-not-contain-word"
    id = "T5"
    target = CommitMessageTitle
    options_spec = [ListOption('words', ["WIP"], "Must not contain word")]
    violation_message = "Title contains the word '{0}' (case-insensitive)"


class TitleLeadingWhitespace(LeadingWhiteSpace):
    name = "title-leading-whitespace"
    id = "T6"
    target = CommitMessageTitle
    violation_message = "Title has leading whitespace"


class TitleRegexMatches(LineRule):
    name = "title-match-regex"
    id = "T7"
    target = CommitMessageTitle
    options_spec = [RegexOption('regex', None, "Regex the title should match")]

    def validate(self, title, _commit):
        # If no regex is specified, immediately return
        if not self.options['regex'].value:
            return

        if not self.options['regex'].value.search(title):
            violation_msg = f"Title does not match regex ({self.options['regex'].value.pattern})"
            return [RuleViolation(self.id, violation_msg, title)]


class TitleMinLength(LineRule):
    name = "title-min-length"
    id = "T8"
    target = CommitMessageTitle
    options_spec = [IntOption('min-length', 5, "Minimum required title length")]

    def validate(self, title, _commit):
        min_length = self.options['min-length'].value
        actual_length = len(title)
        if actual_length < min_length:
            violation_message = f"Title is too short ({actual_length}<{min_length})"
            return [RuleViolation(self.id, violation_message, title, 1)]


class BodyMaxLineLength(MaxLineLength):
    name = "body-max-line-length"
    id = "B1"
    target = CommitMessageBody


class BodyTrailingWhitespace(TrailingWhiteSpace):
    name = "body-trailing-whitespace"
    id = "B2"
    target = CommitMessageBody


class BodyHardTab(HardTab):
    name = "body-hard-tab"
    id = "B3"
    target = CommitMessageBody


class BodyFirstLineEmpty(CommitRule):
    name = "body-first-line-empty"
    id = "B4"

    def validate(self, commit):
        if len(commit.message.body) >= 1:
            first_line = commit.message.body[0]
            if first_line != "":
                return [RuleViolation(self.id, "Second line is not empty", first_line, 2)]


class BodyMinLength(CommitRule):
    name = "body-min-length"
    id = "B5"
    options_spec = [IntOption('min-length', 20, "Minimum body length")]

    def validate(self, commit):
        min_length = self.options['min-length'].value
        body_message_no_newline = "".join([line for line in commit.message.body if line is not None])
        actual_length = len(body_message_no_newline)
        if 0 < actual_length < min_length:
            violation_message = f"Body message is too short ({actual_length}<{min_length})"
            return [RuleViolation(self.id, violation_message, body_message_no_newline, 3)]


class BodyMissing(CommitRule):
    name = "body-is-missing"
    id = "B6"
    options_spec = [BoolOption('ignore-merge-commits', True, "Ignore merge commits")]

    def validate(self, commit):
        # ignore merges when option tells us to, which may have no body
        if self.options['ignore-merge-commits'].value and commit.is_merge_commit:
            return
        if len(commit.message.body) < 2 or not ''.join(commit.message.body).strip():
            return [RuleViolation(self.id, "Body message is missing", None, 3)]


class BodyChangedFileMention(CommitRule):
    name = "body-changed-file-mention"
    id = "B7"
    options_spec = [ListOption('files', [], "Files that need to be mentioned")]

    def validate(self, commit):
        violations = []
        for needs_mentioned_file in self.options['files'].value:
            # if a file that we need to look out for is actually changed, then check whether it occurs
            # in the commit msg body
            if needs_mentioned_file in commit.changed_files:
                if needs_mentioned_file not in " ".join(commit.message.body):
                    violation_message = f"Body does not mention changed file '{needs_mentioned_file}'"
                    violations.append(RuleViolation(self.id, violation_message, None, len(commit.message.body) + 1))
        return violations if violations else None


class BodyRegexMatches(CommitRule):
    name = "body-match-regex"
    id = "B8"
    options_spec = [RegexOption('regex', None, "Regex the body should match")]

    def validate(self, commit):
        # If no regex is specified, immediately return
        if not self.options['regex'].value:
            return

        # We intentionally ignore the first line in the body as that's the empty line after the title,
        # which most users are not going to expect to be part of the body when matching a regex.
        # If this causes contention, we can always introduce an option to change the behavior in a backward-
        # compatible way.
        body_lines = commit.message.body[1:] if len(commit.message.body) > 1 else []

        # Similarly, the last line is often empty, this has to do with how git returns commit messages
        # User's won't expect this, so prune it off by default
        if body_lines and body_lines[-1] == "":
            body_lines.pop()

        full_body = "\n".join(body_lines)

        if not self.options['regex'].value.search(full_body):
            violation_msg = f"Body does not match regex ({self.options['regex'].value.pattern})"
            return [RuleViolation(self.id, violation_msg, None, len(commit.message.body) + 1)]


class AuthorValidEmail(CommitRule):
    name = "author-valid-email"
    id = "M1"
    options_spec = [RegexOption('regex', r"[^@ ]+@[^@ ]+\.[^@ ]+", "Regex that author email address should match")]

    def validate(self, commit):
        # If no regex is specified, immediately return
        if not self.options['regex'].value:
            return

        if commit.author_email and not self.options['regex'].value.match(commit.author_email):
            return [RuleViolation(self.id, "Author email for commit is invalid", commit.author_email)]


class IgnoreByTitle(ConfigurationRule):
    name = "ignore-by-title"
    id = "I1"
    options_spec = [RegexOption('regex', None, "Regex matching the titles of commits this rule should apply to"),
                    StrOption('ignore', "all", "Comma-separated list of rules to ignore")]

    def apply(self, config, commit):
        # If no regex is specified, immediately return
        if not self.options['regex'].value:
            return

        if self.options['regex'].value.match(commit.message.title):
            config.ignore = self.options['ignore'].value

            message = f"Commit title '{commit.message.title}' matches the regex " + \
                      f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}"

            self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)


class IgnoreByBody(ConfigurationRule):
    name = "ignore-by-body"
    id = "I2"
    options_spec = [RegexOption('regex', None, "Regex matching lines of the body of commits this rule should apply to"),
                    StrOption('ignore', "all", "Comma-separated list of rules to ignore")]

    def apply(self, config, commit):
        # If no regex is specified, immediately return
        if not self.options['regex'].value:
            return

        for line in commit.message.body:
            if self.options['regex'].value.match(line):
                config.ignore = self.options['ignore'].value

                message = f"Commit message line '{line}' matches the regex '{self.options['regex'].value.pattern}'," + \
                          f" ignoring rules: {self.options['ignore'].value}"

                self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
                # No need to check other lines if we found a match
                return


class IgnoreBodyLines(ConfigurationRule):
    name = "ignore-body-lines"
    id = "I3"
    options_spec = [RegexOption('regex', None, "Regex matching lines of the body that should be ignored")]

    def apply(self, _, commit):
        # If no regex is specified, immediately return
        if not self.options['regex'].value:
            return

        new_body = []
        for line in commit.message.body:
            if self.options['regex'].value.match(line):
                debug_msg = "Ignoring line '%s' because it matches '%s'"
                self.log.debug(debug_msg, line, self.options['regex'].value.pattern)
            else:
                new_body.append(line)

        commit.message.body = new_body
        commit.message.full = "\n".join([commit.message.title] + new_body)


class IgnoreByAuthorName(ConfigurationRule):
    name = "ignore-by-author-name"
    id = "I4"
    options_spec = [RegexOption('regex', None, "Regex matching the author name of commits this rule should apply to"),
                    StrOption('ignore', "all", "Comma-separated list of rules to ignore")]

    def apply(self, config, commit):
        # If no regex is specified, immediately return
        if not self.options['regex'].value:
            return

        if self.options['regex'].value.match(commit.author_name):
            config.ignore = self.options['ignore'].value

            message = (f"Commit Author Name '{commit.author_name}' matches the regex "
                       f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}")

            self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
            # No need to check other lines if we found a match
            return
