"""
Contains the abstraction for a commit in GitHub.
"""
from typing import Optional
from typing import Set

from IGitt import ElementDoesntExistError
from IGitt.GitHub import GitHubMixin, GitHubToken
from IGitt.GitHub.GitHubComment import GitHubComment
from IGitt.GitHub.GitHubRepository import GitHubRepository
from IGitt.GitHub.GitHubIssue import GitHubIssue
from IGitt.Interfaces import get, post
from IGitt.Interfaces.Comment import CommentType
from IGitt.Interfaces.Commit import Commit
from IGitt.Interfaces.CommitStatus import CommitStatus, Status

GH_STATE_TRANSLATION = {Status.ERROR: 'error', Status.FAILED: 'failure',
                        Status.PENDING: 'pending', Status.CANCELED: 'failure',
                        Status.SUCCESS: 'success', Status.RUNNING: 'pending',
                        Status.CREATED: 'pending', Status.MANUAL: 'pending',
                        Status.SKIPPED: 'success'}
INV_GH_STATE_TRANSLATION = {'error': Status.ERROR, 'failure': Status.FAILED,
                            'pending': Status.PENDING,
                            'success': Status.SUCCESS}
GITHUB_KEYWORD_REGEX = {'fix': r'[Ff]ix(?:e[sd])?',
                        'close': r'[Cc]lose[sd]?',
                        'resolve': r'[Rr]esolve[sd]?'}


def get_diff_index(patch, line_nr):
    r"""
    Takes a patch and receives the position of the given line number in it.

    >>> patch = ('---/version/a\n'
    ...          '+++/version/b\n'
    ...          '@@ -1,2 +1,4 @@\n'
    ...          ' # test\n'  # Line 1
    ...          '+\n'  # Line 2
    ...          '-a test repo\n'  # Line 3
    ...          '+something new\n'  # Line 3
    ...          ' something old\n')  # Line 4
    >>> get_diff_index(patch, 1)
    1
    >>> get_diff_index(patch, 3)
    4
    >>> get_diff_index(patch, 4)
    5

    If the line isn't covered in the patch, it'll return None:

    >>> get_diff_index(patch, 8)

    Sometimes patches contain the function and shall still be interpreted
    correctly:

    >>> patch = ('@@ -464,11 +464,10 @@ def get_action_info(section, action):\n'
    ...          ' line 464\n'
    ...          ' line 465\n'
    ...          ' line 466\n'
    ...          '-line 467\n'
    ...          '-line 468\n'
    ...          '+line 467\n'
    ...          '+line 468\n'
    ...          ' line 469\n')
    >>> get_diff_index(patch, 465)
    2
    >>> get_diff_index(patch, 467)
    6

    :param patch: A list of lines of a unified diff.
    :param line_nr: The line number to identify.
    :return: The position in the Patch or None
    """
    patch = patch.splitlines(True)
    current_line_added = 0
    current_diff_index = 0
    for line in patch:
        if line.startswith('---') or line.startswith('+++'):
            continue

        if line.startswith('@@'):
            values = line[line.find('-'):line.find(' @@', 3)]
            _, added = tuple(values.split(' '))
            current_line_added = int(added.split(',')[0][1:])
        elif line.startswith('+') or line.startswith(' '):
            if current_line_added == line_nr:
                return current_diff_index

            current_line_added += 1

        current_diff_index += 1

    return None


class GitHubCommit(GitHubMixin, Commit):
    """
    Represents a commit on GitHub.
    """

    def __init__(self, token: GitHubToken, repository: str, sha: str):
        """
        Creates a new github commit object.

        :param token: A GitHubToken object for authentication.
        :param repository: The full repository name.
        :param sha: The commit SHA.
        """
        self._token = token
        self._repository = repository
        self._sha = sha
        self._url = '/repos/' + repository + '/commits/' + sha

    @property
    def message(self) -> str:
        """
        Returns the commit message.

        :return: Commit message as string.
        """
        return self.data['commit']['message']

    @property
    def sha(self):
        """
        Retrieves the SHA of the commit:

        >>> from os import environ
        >>> commit = GitHubCommit(GitHubToken(environ['GITHUB_TEST_TOKEN']),
        ...                       'gitmate-test-user/test', '674498')
        >>> commit.sha
        '674498'

        :return: A string holding the SHA of the commit.
        """
        return self._sha

    @property
    def repository(self):
        """
        Retrieves the repository that holds this commit.

        >>> from os import environ
        >>> commit = GitHubCommit(GithubToken(environ['GITHUB_TEST_TOKEN']),
        ...                       'gitmate-test-user/test', '3fc4b86')
        >>> commit.repository.full_name
        'gitmate-test-user/test'

        :return: A usable Repository instance.
        """
        return GitHubRepository(self._token, self._repository)

    @property
    def parent(self):
        """
        Retrieves the parent commit. In case of a merge commit the first parent
        will be returned.

        >>> from os import environ
        >>> commit = GitHubCommit(GithubToken(environ['GITHUB_TEST_TOKEN']),
        ...                       'gitmate-test-user/test', '3fc4b86')
        >>> commit.parent.sha
        '674498fd415cfadc35c5eb28b8951e800f357c6f'

        :return: A Commit object.
        """
        return GitHubCommit(self._token, self._repository,
                            self.data['parents'][0]['sha'])

    def set_status(self, status: CommitStatus):
        """
        Adds the given status to the commit.

        >>> from os import environ
        >>> commit = GitHubCommit(GithubToken(environ['GITHUB_TEST_TOKEN']),
        ...                       'gitmate-test-user/test', '3fc4b86')
        >>> status = CommitStatus(Status.FAILED, 'Theres a problem',
        ...                       'gitmate/test')
        >>> commit.set_status(status)
        >>> commit.get_statuses().pop().description
        'Theres a problem'

        If a status with the same context already exists, it will be bluntly
        overridden:

        >>> status.status = Status.SUCCESS
        >>> status.description = "Theres no problem"
        >>> commit.set_status(status)
        >>> len(commit.get_statuses())
        2
        >>> commit.get_statuses().pop().description
        'This commit needs work.'

        :param status: The CommitStatus to set to this commit.
        :raises RuntimeError: If something goes wrong (network, auth...).
        """
        data = {'state': GH_STATE_TRANSLATION[status.status],
                'target_url': status.url, 'description': status.description,
                'context': status.context}
        status_url = '/repos/' + self._repository + '/statuses/' + self.sha
        post(self._token, self.absolute_url(status_url), data)

    def get_statuses(self) -> Set[CommitStatus]:
        """
        Retrieves the all commit statuses.

        :return: A (frozen)set of CommitStatus objects.
        :raises RuntimeError: If something goes wrong (network, auth...).
        """
        url = self.url + '/statuses'
        statuses = get(self._token, url)

        # Only the first of each context is the one we want
        result = set()
        contexts = set()
        for status in statuses:
            if status['context'] not in contexts:
                result.add(CommitStatus(
                    INV_GH_STATE_TRANSLATION[status['state']],
                    status['description'], status['context'],
                    status['target_url']))
                contexts.add(status['context'])

        return result

    @property
    def combined_status(self) -> Status:
        """
        Retrieves a combined status of all the commits.

        :return:
            Status.FAILED if any of the commits report as error or failure or
            canceled
            Status.PENDING if there are no statuses or a commit is pending or a
            test is running
            Status.SUCCESS if the latest status for all commits is success
        """
        url = self.url + '/status'
        return INV_GH_STATE_TRANSLATION[get(self._token, url)['state']]

    def get_patch_for_file(self, filename: str):
        r"""
        Retrieves the patch for the given file:

        >>> from os import environ
        >>> commit = GitHubCommit(GithubToken(environ['GITHUB_TEST_TOKEN']),
        ...                       'gitmate-test-user/test', '3fc4b86')
        >>> commit.get_patch_for_file('README.md')
        '@@ -1,2 +1,4 @@\n # test\n a test repo\n+\n+a tst pr'

        But only if it exists!

        >>> commit.get_patch_for_file('isnt there!')
        Traceback (most recent call last):
         ...
        IGitt.ElementDoesntExistError: The file does not exist.

        :param filename: The file to receive the patch for.
        :return: A string containing the patch.
        :raises ElementDoesntExistError: If the given filename doesn't exist.
        """
        for file in self.data['files']:
            if file['filename'] == filename and 'patch' in file:
                return file['patch']

        raise ElementDoesntExistError('The file does not exist.')

    def comment(self, message: str, file: Optional[str]=None,
                line: Optional[int]=None,
                mr_number: Optional[int]=None) -> GitHubComment:
        """
        Places a comment on the commit.

        >>> from os import environ
        >>> commit = GitHubCommit(GithubToken(environ['GITHUB_TEST_TOKEN']),
        ...                       'gitmate-test-user/test', '3fc4b86')

        So this line places a comment on the bottom of the commit,
        not associated to any particular piece of code:

        >>> commit.comment("An issue is here!")

        However, we can also comment on a particular file and line, if that is
        included in the diff:

        >>> commit.comment("Here in line 4, there's a spelling mistake!",
        ...                'README.md', 4)

        If you supply the ``pr_number`` argument, the comment will appear in the
        review UI of that pull request:

        >>> commit.comment("Here in line 4, there's a spelling mistake!",
        ...                'README.md', 4, mr_number=6)

        Beat that! Of course, there's a lot of error handling. If you give the
        wrong file, the comment will appear below the commit with a note about
        the commit, file and line:

        >>> commit.comment("Oh, this'll end up below!!", 'READMENOT.md', 4)

        Also if the line isn't contained in the diff GitHub won't accept that
        and it'll also end up below - sorry!

        >>> commit.comment("Oh, this'll too end up below!!", 'README.md', 8)

        If you give a pull request, the comment will appear on the PR instead:

        >>> commit.comment("Oh, this'll too end up on the PR.",
        ...                'README.md', 8, mr_number=6)

        :param message: The body of the comment.
        :param file: The file to place the comment, relative to repository root.
        :param line: The line in the file in the comment or None.
        :param mr_number: The number of a merge request if this should end up in
                          the review UI of the merge request.
        """
        data = {'body': message}

        if file is not None and line is not None:
            try:
                patch = self.get_patch_for_file(file)
                index = get_diff_index(patch, line)
                if index:  # Else, fallback to comment below file
                    data['position'] = index
                    data['path'] = file
            except ElementDoesntExistError:
                pass  # Fallback to comment below the file

        if 'position' not in data:
            file_str = '' if file is None else ', file ' + file
            line_str = '' if line is None else ', line ' + str(line)
            data['body'] = ('Comment on ' + self.sha + file_str + line_str +
                            '.\n\n' + data['body'])

        comment_type = None

        if mr_number is None:
            comment_type = CommentType.COMMIT
            res = post(self._token, self.url + '/comments', data)
        elif 'position' in data:
            comment_type = CommentType.REVIEW
            data['commit_id'] = self.sha
            res = post(
                self._token,
                self.absolute_url(
                    '/repos/' + self._repository + '/pulls/' + str(mr_number) +
                    '/comments'),
                data)
        else:  # Position not available, pr number available, comment on PR
            comment_type = CommentType.ISSUE
            res = post(
                self._token,
                self.absolute_url(
                    '/repos/' + self._repository + '/issues/' + str(mr_number) +
                    '/comments'),
                data)

        return GitHubComment.from_data(res, self._token, self._repository,
                                       comment_type, res['id'])

    @property
    def unified_diff(self):
        """
        Retrieves the unified diff for the commit excluding the diff index.
        """
        difflines = str(get(self._token, self.url, headers={
            'Accept': 'application/vnd.github.v3.diff'
        })).strip().splitlines()
        # getting rid of the indexing stuff from git diff e.g. removing lines
        # ``diff --git a/somefile b/somefile
        # index 1da2df..2dacdf 100644``
        return '\n'.join([diff for diff in difflines
                          if not diff.startswith('diff --git') and
                          not diff.startswith('index')])

    @property
    def closes_issues(self) -> Set[GitHubIssue]:
        """
        Returns a set of GitHubIssue objects which would be closed upon merging
        this commit.
        """
        return {GitHubIssue(self._token, repo_name, number)
                for number, repo_name in self._get_closes_issues()}

    @property
    def mentioned_issues(self) -> Set[GitHubIssue]:
        """
        Returns a set of GitHubIssue objects which are related to the commit.
        """
        return {GitHubIssue(self._token, repo_name, number)
                for number, repo_name in self._get_mentioned_issues()}


    @property
    def will_fix_issues(self) -> Set[GitHubIssue]:
        """
        Returns a set of GitHubIssue objects which would be fixed as stated in
        this commit message.
        """
        issues =  self.get_keywords_issues(GITHUB_KEYWORD_REGEX['fix'],
                                           [self.message])
        return {GitHubIssue(self._token, repo_name, number)
                for number, repo_name in issues}

    @property
    def will_close_issues(self) -> Set[GitHubIssue]:
        """
        Returns a set of GitHubIssue objects which would be closed as stated in
        this commit message.
        """
        issues =  self.get_keywords_issues(GITHUB_KEYWORD_REGEX['close'],
                                           [self.message])
        return {GitHubIssue(self._token, repo_name, number)
                for number, repo_name in issues}

    @property
    def will_resolve_issues(self) -> Set[GitHubIssue]:
        """
        Returns a set of GitHubIssue objects which would be resolved as stated
        in this commit message.
        """
        issues =  self.get_keywords_issues(GITHUB_KEYWORD_REGEX['resolve'],
                                           [self.message])
        return {GitHubIssue(self._token, repo_name, number)
                for number, repo_name in issues}
