"""
This contains the Issue implementation for GitLab.
"""
from datetime import datetime
from datetime import timedelta
from typing import List
from typing import Optional
from typing import Set
from typing import Union
from urllib.parse import quote_plus

from IGitt.GitLab import GitLabMixin
from IGitt.GitLab import GitLabOAuthToken, GitLabPrivateToken
from IGitt.GitLab.GitLabComment import GitLabComment
from IGitt.GitLab.GitLabReaction import GitLabReaction
from IGitt.GitLab.GitLabUser import GitLabUser
from IGitt.Interfaces.Comment import CommentType
from IGitt.Interfaces.Issue import Issue
from IGitt.Interfaces import get, put, post, delete
from IGitt.Interfaces import IssueStates
from IGitt.Interfaces import MergeRequestStates


class GitLabIssue(GitLabMixin, Issue):
    """
    This class represents an issue on GitLab.
    """

    def __init__(self, token: Union[GitLabOAuthToken, GitLabPrivateToken],
                 repository: str, number: int):
        """
        Creates a new GitLabIssue with the given credentials.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/repo_that_doesnt_exist', 1)
        ... # doctest: +IGNORE_EXCEPTION_DETAIL
        Traceback (most recent call last):
        ...
        RuntimeError: ({'message': 'Not Found', ...}, 404)

        :param token: A Token object to be used for authentication.
        :param repository: The full name of the repository.
                           e.g. ``sils/baritone``.
        :param number: The issue internal identification number.
        :raises RuntimeError: If something goes wrong (network, auth, ...)
        """
        self._token = token
        self._repository = repository
        self._iid = number
        self._url = '/projects/{repo}/issues/{issue_iid}'.format(
            repo=quote_plus(repository), issue_iid=number)

    @property
    def repository(self):
        """
        Returns the GitLab repository this issue is linked with as a
        GitLabRepository instance.
        """
        from IGitt.GitLab.GitLabRepository import GitLabRepository
        return GitLabRepository(self._token, self._repository)

    @property
    def title(self) -> str:
        """
        Retrieves the title of the issue.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)

        >>> issue.title
        'Take it serious, son!'

        You can simply set it using the property setter:

        >>> issue.title = 'dont panic'
        >>> issue.title
        'dont panic'

        >>> issue.title = 'Take it serious, son!'

        :return: The title of the issue - as string.
        """
        return self.data['title']

    @title.setter
    def title(self, new_title):
        """
        Sets the title of the issue.

        :param new_title: The new title.
        """
        self.data = put(self._token, self.url, {'title': new_title})

    @property
    def number(self) -> int:
        """
        Returns the issue "number" or id.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)
        >>> issue.number
        1

        :return: The number of the issue.
        """
        return self._iid

    @property
    def assignees(self):
        """
        Retrieves the assignee of the issue:

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)
        >>> issue.assignees
        {'gitmate-test-user'}

        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 2)
        >>> issue.assignees # Returns empty set, unassigned
        {}

        :return: A set containing the usernames of assignees.
        """
        return {GitLabUser.from_data(user, self._token, user['id'])
                for user in self.data['assignees']}

    def assign(self, *usernames: List[GitLabUser]):
        """
        Adds the user as one of the assignees of the issue.
        :param users: User objects of the users to be added as an assignee.
        """
        self.assignees |= set(usernames)

    def unassign(self, *users: List[GitLabUser]):
        """
        Removes the user from the assignees of the issue.
        :param users: User objects of the users to be unassigned.
        """
        self.assignees = self.assignees - set(users)

    @assignees.setter
    def assignees(self, value: Set[GitLabUser]):
        """
        Setter for assignees.
        """
        self.data = put(self._token, self.url,
                        {'assignee_ids': [u.identifier for u in value]})

    @property
    def available_assignees(self) -> Set[GitLabUser]:
        """
        Retrieves a set of available assignees for the issue.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)
        >>> {a.username for a in issue.available_assignees}
        {'gitmate-test-user'}

        :return: A set of GitLabUsers.
        """
        return {
            GitLabUser.from_data(user, self._token, user['id'])
            for user in get(
                self._token, self.absolute_url(
                    '/projects/' + quote_plus(self._repository) + '/members'))
        }

    @property
    def description(self) -> str:
        r"""
        Retrieves the main description of the issue.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)
        >>> issue.description
        'I am a serious issue. Fix me soon, dude.'

        :return: A string containing the main description of the issue.
        """
        return self.data['description'] if self.data['description'] else ''

    @description.setter
    def description(self, new_description):
        """
        Sets the description of the issue.

        :param new_description: The new description.
        """
        self.data = put(self._token, self.url, {'description': new_description})

    @property
    def author(self) -> GitLabUser:
        """
        Retrieves the author of the issue.

        :return: A GitLabUser object.
        """
        return GitLabUser.from_data(self.data['author'],
                                    self._token,
                                    self.data['author']['id'])

    def add_comment(self, body):
        """
        Adds a comment to the issue.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)
        >>> comment = issue.add_comment("Doh!")

        You can use the comment right after:

        >>> comment.body
        'Doh!'
        >>> comment.delete()

        The comment will be created by the user authenticated via the oauth
        token.

        :param body: The body of the new comment to create.
        :return: The newly created comment.
        """
        result = post(self._token, self.url + '/notes', {'body': body})

        return GitLabComment(self._token, self._repository, self.number,
                             CommentType.ISSUE, result['id'])

    @property
    def comments(self) -> List[GitLabComment]:
        r"""
        Retrieves comments from the issue.

        As of now, the list of comments is not sorted. Related issue on GitLab
        CE here - https://gitlab.com/gitlab-org/gitlab-ce/issues/32057

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 3)
        >>> comments = issue.comments
        >>> for comment in comments:
        ...     print(comment.body)
        Stop staring at me.
        Go, get your work done.

        :return: A list of Comment objects.
        """
        return [
            GitLabComment.from_data(
                result, self._token, self._repository, self.number,
                CommentType.ISSUE, result['id']
            )
            for result in get(self._token, self.url + '/notes')
        ]

    @property
    def labels(self) -> Set[str]:
        """
        Retrieves all labels associated with this bug.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)
        >>> issue.labels
        set()

        Use the property setter to set the labels:

        >>> issue.labels = {'a', 'b', 'c'}
        >>> sorted(issue.labels)
        ['a', 'b', 'c']

        Use the empty set intuitively to clear all labels:

        >>> issue.labels = set()

        :return: A list of label captions (str).
        """
        return set(self.data['labels'])

    @labels.setter
    def labels(self, value: Set[str]):
        """
        Sets the value of labels to the given set of labels.

        :param value: A set of label texts.
        """
        # Only if self.data is populated we actually save a request here
        if 'labels' in self.data and value == self.labels:
            return  # No need to patch

        self.data = put(self._token, self.url,
                        {'labels': ','.join(map(str, value))})

    @property
    def available_labels(self) -> Set[str]:
        """
        Retrieves a set of captions that are available for labelling bugs.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)
        >>> sorted(issue.available_labels)
        ['a', 'b', 'c']

        :return: A set of label captions (str).
        """
        return {label['name'] for label in get(
            self._token, self.absolute_url(
                '/projects/' + quote_plus(self._repository) + '/labels'))}

    @property
    def created(self)->datetime:
        """
        Retrieves a timestamp on when the issue was created.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 4)
        >>> issue.created
        datetime.datetime(2017, 6, 5, 9, 45, 20, 678000)
        """
        return datetime.strptime(self.data['created_at'],
                                 '%Y-%m-%dT%H:%M:%S.%fZ')

    @property
    def updated(self) -> datetime:
        """
        Retrieves a timestamp on when the issue was updated the last time.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 4)
        >>> issue.updated
        datetime.datetime(2017, 6, 5, 9, 45, 56, 115000)
        """
        return datetime.strptime(self.data['updated_at'],
                                 '%Y-%m-%dT%H:%M:%S.%fZ')

    def close(self):
        """
        Closes the issue.

        :raises RuntimeError: If something goes wrong (network, auth...).
        """
        self.data = put(self._token, self.url, {'state_event': 'close'})

    def reopen(self):
        """
        Reopens the issue.

        :raises RuntimeError: If something goes wrong (network, auth...).
        """
        self.data = put(self._token, self.url, {'state_event': 'reopen'})

    def delete(self):
        """
        Deletes the issue.

        :raises RuntimeError: If something goes wrong (network, auth...).
        """
        delete(self._token, self.url)

    @property
    def state(self):
        """
        Get's the state of the issue.

        >>> from os import environ
        >>> issue = GitLabIssue(GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...                     'gitmate-test-user/test', 1)
        >>> issue.state
        <IssueStates.OPEN: 'open'>
        >>> str(issue.state)
        'open'

        So if we close it:

        >>> issue.close()
        >>> issue.state
        <IssueStates.CLOSED: 'closed'>
        >>> str(issue.state)
        'closed'

        And reopen it:

        >>> issue.reopen()
        >>> issue.state
        <IssueStates.OPEN: 'open'>


        Note: GitLab Issues & Merge Requests API underwent a change to have
        only two states, <IssueStates.OPEN: 'open'> or
        <IssueStates.CLOSED: 'closed'>. No 'reopened' state anymore.

        :return:
            Either <IssueStates.OPEN: 'open'> or
            <IssueStates.CLOSED: 'closed'>.
        """
        if self.data['state'] == 'opened':
            self.data['state'] = 'open'

        return IssueStates[self.data['state'].upper()]

    @property
    def reactions(self) -> Set[GitLabReaction]:
        """
        Retrieves the reactions / award emojis applied on the issue.
        """
        url = self.url + '/award_emoji'
        reactions = get(self._token, url)
        return {GitLabReaction.from_data(r, self._token, self, r['id'])
                for r in reactions}

    @staticmethod
    def create(token: Union[GitLabOAuthToken, GitLabPrivateToken],
               repository: str,
               title: str,
               body: str='',
               issue_type: Optional[str]=None):
        """
        Create a new issue with given title and body.

        >>> from os import environ
        >>> issue = GitLabIssue.create(
        ...     GitLabOAuthToken(environ['GITLAB_TEST_TOKEN']),
        ...     'gitmate-test-user/test',
        ...     'test issue title',
        ...     'sample description'
        ... )
        >>> issue.state
        <IssueStates.OPEN: 'open'>

        Delete the issue to avoid filling the test repo with issues.

        >>> issue.delete()

        :return: GitLabIssue object of the newly created issue.
        """
        url = '/projects/{repo}/issues'.format(repo=quote_plus(repository))
        issue = post(token, GitLabIssue.absolute_url(url),
                     {'title': title, 'description': body})

        return GitLabIssue.from_data(issue, token, repository, issue['iid'])

    @property
    def mrs_closed_by(self):
        """
        Returns the merge requests that close this issue.
        """
        from IGitt.GitLab.GitLabMergeRequest import GitLabMergeRequest

        url = '{url}/closed_by'.format(url=self._url)
        mrs = get(self._token, self.absolute_url(url))

        return {GitLabMergeRequest.from_data(mr,
                                             self._token,
                                             self._repository,
                                             mr['iid'])
                for mr in mrs if mr['state'] == MergeRequestStates.MERGED.value}

    @property
    def milestone(self):
        """
        Retrieves the milestone.
        """
        from IGitt.GitLab.GitLabProjectMilestone import GitLabProjectMilestone
        return GitLabProjectMilestone.from_data(
            self.data['milestone'], self._token, self._repository,
            self.data['milestone']['id']
        ) if self.data['milestone'] else None

    @milestone.setter
    def milestone(self, new_milestone):
        """
        Setter for the Milestone.
        Delete the Milestone with passing a 'None'
        """
        self.data = put(
            self._token, self.url,
            {'milestone_id': new_milestone.number if new_milestone else ''})

    @property
    def time_estimate(self) -> timedelta:
        """
        Retrieves the time_estimate from the 'time_estimate' attribute
        in the GitLab Issue.
        """
        return timedelta(seconds=self.data['time_stats']['time_estimate'])

    @time_estimate.setter
    def time_estimate(self, new_time_estimate: timedelta):
        """
        Setter for the time_estimate.
        Sets the time on 'human_time_estimate' since it's not possible to set
        'time_estimate'. GitLab then automatically calculates 'time_estimate'.
        time_estimate is set to 0 when passing a None or 0.
        """
        if new_time_estimate:
            human_time_estimate = \
                            str(int(new_time_estimate.total_seconds())) + 's'
            self.data = post(self._token, self.url +
                             '/time_estimate',
                             {'duration': human_time_estimate})
        else:
            self.data = post(self._token, self.url +
                             '/reset_time_estimate', {'duration': None})

    @property
    def total_time_spent(self) -> timedelta:
        """
        Retrieves the time_estimate from the 'total_time_spent' attribute
        in the GitLab Issue.
        """
        return timedelta(seconds=self.data['time_stats']['total_time_spent'])

    @total_time_spent.setter
    def total_time_spent(self, absolute_time_spent: timedelta):
        """
        Writes the value of absolute_time_spent into total_time_spent.
        Can't be less than 0.
        Allows any time unit of the timedelta object.
        Allows total_time_spent to be reset to 0 by passing a None or 0.
        """
        if absolute_time_spent and absolute_time_spent.total_seconds() != 0:
            self.data = post(self._token, self.url +
                             '/reset_spent_time', {'duration': None})
            human_time_spent = \
                        str(int(absolute_time_spent.total_seconds())) + 's'
            self.data = post(self._token, self.url +
                             '/add_spent_time',
                             {'duration': human_time_spent})
        else:
            self.data = post(self._token, self.url +
                             '/reset_spent_time', {'duration': None})

    def add_to_total_time_spent(self, relative_time_spent: timedelta):
        """
        Adds the value of relative_time_spent to total_time_spent.
        Allows for positive and negative values.
        Allows any time unit of the timedelta object.
        Does nothing when passing None or 0.
        """
        if relative_time_spent and relative_time_spent.total_seconds() != 0:
            human_time_spent = \
                        str(int(relative_time_spent.total_seconds())) + 's'
            self.data = post(self._token, self.url +
                             '/add_spent_time',
                             {'duration': human_time_spent})
