#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

"""WebCompat Issue Model for webhooks."""

from dataclasses import dataclass
from typing import List
from datetime import datetime
from elasticsearch import Elasticsearch

from requests.exceptions import HTTPError, ConnectionError

from webcompat import app
from webcompat.webhooks.helpers import extract_metadata
from webcompat.webhooks.helpers import get_issue_labels
from webcompat.webhooks.helpers import make_response
from webcompat.webhooks.helpers import make_request
from webcompat.webhooks.helpers import msg_log
from webcompat.webhooks.helpers import oops
from webcompat.webhooks.helpers import prepare_rejected_issue
from webcompat.webhooks.helpers import repo_scope
from webcompat.webhooks.helpers import prepare_private_url
from webcompat.webhooks.helpers import extract_nsfw_label
from webcompat.webhooks.ml import get_issue_classification
from webcompat.issues import moderation_template

PUBLIC_REPO = app.config['ISSUES_REPO_URI']
PRIVATE_REPO = app.config['PRIVATE_REPO_URI']
AUTOCLOSED_MILESTONE_ID = app.config['AUTOCLOSED_MILESTONE_ID']
THRESHOLD = 0.97

if app.config['ES_LOG_ENABLED']:
    ES = Elasticsearch(
        app.config['ES_URL'],
        api_key=(
            app.config['ES_API_KEY_ID'],
            app.config['ES_API_KEY']
        )
    )


@dataclass
class WebHookIssue:
    """WebCompat Issue Model for WebHook consumption"""
    action: str
    body: str
    domain: str
    number: int
    public_url: str
    repository_url: str
    state: str
    title: str
    original_labels: List[str]
    milestone: str
    milestoned_with: str
    labeled_with: str
    host_reported_from: str
    html_url: str

    @classmethod
    def from_dict(cls, payload, host=None):
        """Class method to allow instantiation from a GitHub response dict."""
        # Extract the title and the body
        issue = payload['issue']
        full_title = issue.get('title', 'Weird_Title - Inspect')
        labels = issue.get('labels', [])
        issue_body = issue['body']
        domain = full_title.partition(' ')[0]
        public_url = extract_metadata(issue_body).get('public_url', '').strip()
        original_labels = [label['name'] for label in labels]
        html_url = issue.get('html_url')
        # webhook with a milestone already set
        milestone = ''
        if issue.get('milestone'):
            milestone = issue['milestone']['title']
        # webhook with a milestoned action
        milestoned_with = ''
        if payload.get('milestone'):
            milestoned_with = payload.get('milestone')['title']

        # labeled/unlabeled action
        labeled_with = ''
        if payload.get('label'):
            labeled_with = payload.get('label')['name']

        host_reported_from = ''
        if host:
            host_reported_from = host

        return cls(action=payload['action'], body=issue_body,
                   domain=domain, number=issue.get('number'),
                   public_url=public_url,
                   repository_url=issue.get('repository_url'),
                   state=issue.get('state'), title=full_title,
                   original_labels=original_labels,
                   milestone=milestone, milestoned_with=milestoned_with,
                   host_reported_from=host_reported_from, html_url=html_url,
                   labeled_with=labeled_with)

    def close_private_issue(self):
        """Mark the private issue as closed."""
        path = f'repos/{PRIVATE_REPO}/{self.number}'
        try:
            make_request('patch', path, {'state': 'closed'})
        except HTTPError as e:
            # pass the error up to process_issue_action
            raise e
        else:
            self.state = 'closed'

    def comment_closed_reason(self, reason):
        """Publish a comment on the public issue about why it was closed."""
        if reason in ['invalid', 'incomplete']:
            comment = moderation_template(reason).get('body')
        else:
            raise ValueError("reason must be one of invalid or incomplete")
        payload = {'body': comment}
        issue_number = self.get_public_issue_number()
        path = f'repos/{PUBLIC_REPO}/{issue_number}/comments'
        make_request('post', path, payload)

    def comment_public_uri(self):
        """Publish a comment on the private issue with the public uri."""
        comment = self.prepare_public_comment()
        payload = {'body': comment}
        # Preparing the proxy request
        path = f'repos/{PRIVATE_REPO}/{self.number}/comments'
        make_request('post', path, payload)

    def comment_outreach_generator_uri(self):
        """Publish a comment on the public issue with outreach uri."""
        comment = self.prepare_outreach_comment()
        payload = {'body': comment}
        # Preparing the proxy request
        path = f'repos/{PUBLIC_REPO}/{self.number}/comments'
        make_request('post', path, payload)

    def moderate_private_issue(self):
        """Write the private issue in public.

        Send a GitHub PATCH to set labels and milestone for the issue.

        PATCH /repos/:owner/:repo/issues/:number
        {
            "title": "a string for the title",
            "body": "the full body",
            "labels": ['Label1', 'Label2'],
        }

        Milestone should be already set on needstriage

        we get the destination through the public_url.
        """
        payload_request = self.prepare_accepted_issue()
        public_number = self.get_public_issue_number()
        # Preparing the proxy request
        path = f'repos/{PUBLIC_REPO}/{public_number}'
        make_request('patch', path, payload_request)

    def prepare_accepted_issue(self, milestone=None):
        """Create the payload for the accepted moderated issue.

        When the issue has been moderated as accepted,
        we need to change a couple of things in the public space

        - Title
        - Body
        - Any labels from the private issue
        """
        # Gets the labels from the body
        labels = get_issue_labels(self.body)
        labels.extend(self.original_labels)
        # Let's remove action-needsmoderation in case it's here
        if 'action-needsmoderation' in labels:
            labels.remove('action-needsmoderation')
        # Prepares the payload
        payload_request = {
            'labels': labels,
            'title': self.title,
            'body': self.body
        }
        if milestone:
            milestone_id = app.config['STATUSES'][f'{milestone}']['id']
            payload_request['milestone'] = milestone_id
            payload_request['state'] = 'closed'
        return payload_request

    def prepare_public_comment(self):
        """Build the comment for the public repo."""
        # public issue data
        public_number = self.get_public_issue_number()
        # prepare the payload
        return f'[Original issue {public_number}]({self.public_url})'

    def prepare_outreach_comment(self):
        """Build the comment with a link to the outreach generator page."""
        host = self.host_reported_from
        if not host:
            host = "https://webcompat.com/"
        # prepare the payload
        return f'[Generate outreach template]({host}outreach/{self.number})'

    def close_public_issue(self, reason='rejected'):
        """Close a public issue for the given reason.

        Right now the accepted reasons are:
        'incomplete'
        'invalid'
        'autoclosed'
        'rejected' (default)
        """
        if reason == 'incomplete':
            payload_request = self.prepare_accepted_issue('incomplete')
        elif reason == 'invalid':
            payload_request = self.prepare_accepted_issue('invalid')
        elif reason == 'autoclosed':
            payload_request = prepare_rejected_issue(reason)
            # We add the private issue link to the body of the public issue,
            # and it will be used to make a request during ml data fetching
            # as public issue will no longer have relevant issue data
            payload_request['body'] += prepare_private_url(self.html_url)
        else:
            payload_request = prepare_rejected_issue()
        public_number = self.get_public_issue_number()
        # Preparing the proxy request
        path = f'repos/{PUBLIC_REPO}/{public_number}'
        make_request('patch', path, payload_request)

    def tag_as_public(self):
        """Set the core actions on new opened issues.

        When a new issue is opened, we set a couple of things.

        - Browser label
        - Priority label
        - Issue milestone
        - Any "extra" labels, set from GET params

        Then Send a GitHub PATCH to set labels and milestone for the issue.

        PATCH /repos/:owner/:repo/issues/:number
        {
            "milestone": 2,
            "labels": ['Label1', 'Label2']
        }
        """
        # Grabs the labels already set so they will not be erased
        # Gets the labels from the body
        labels = get_issue_labels(self.body)
        labels.extend(self.original_labels)
        self.milestone = app.config['STATUSES']['needstriage']['id']
        # Preparing the proxy request to the public repo
        path = f'repos/{PUBLIC_REPO}/{self.number}'
        payload_request = {'labels': labels, 'milestone': self.milestone}
        make_request('patch', path, payload_request)

    def get_public_issue_number(self):
        """Extract the issue number from the public url."""
        url = self.public_url
        if url:
            url = self.public_url.strip().rsplit('/', 1)[1]
        return url

    def classify(self):
        """Make a request to bugbug and add a milestone to the issue.

        Gets issue classification from bugbug and adds a milestone
        to the issue if probability is high
        """
        data = get_issue_classification(self.number)
        needsdiagnosis_false = data.get('class')
        proba = data.get('prob')

        if needsdiagnosis_false and proba and proba[1] > THRESHOLD:
            path = f'repos/{PRIVATE_REPO}/{self.number}'
            payload_request = {'milestone': AUTOCLOSED_MILESTONE_ID}
            make_request('patch', path, payload_request)

        if app.config['ES_LOG_ENABLED']:
            # save prediction result to ES
            doc = {
                "issue": int(self.number),
                "issue_url": self.html_url,
                "predicted_at": datetime.now(),
                "prediction": data,
            }

            ES.index(
                index="bugbug_predictions",
                id=int(self.number),
                body=doc,
            )

    def add_bugbug_tracking_label(self, label_name):
        payload = {'labels': [label_name]}
        path = f'repos/{PUBLIC_REPO}/{self.number}/labels'
        make_request('post', path, payload)

    def label_nsfw(self):
        nsfw_label = extract_nsfw_label(self.body)

        if nsfw_label:
            payload = {'labels': [nsfw_label]}
            path = f'repos/{PRIVATE_REPO}/{self.number}/labels'
            make_request('post', path, payload)

    def process_issue_action(self):
        """Route the actions and provide different responses.

        There are two possible known scopes:
        * public repo
        * private repo

        Currently the actions we are handling are (for now):
        * opened (public repo only)
        Aka newly issues created and
        need to be assigned labels and milestones
        * milestoned
        When the issue is moved to needscontact
        When the issue is being moderated with a milestone: accepted
        """
        source_repo = self.repository_url
        scope = repo_scope(source_repo)
        # We do not process further in case
        # we don't know what we are dealing with
        if scope == 'unknown':
            return make_response('Wrong repository', 403)
        if self.action == 'opened' and scope == 'public':
            # we are setting labels on each new open issues
            try:
                self.tag_as_public()
            except HTTPError as e:
                msg_log(f'public:opened labels failed ({e})', self.number)
                return oops()
            else:
                return make_response('gracias, amigo.', 200)
        elif (self.action == 'milestoned' and scope == 'public' and
              self.milestoned_with in ('needsdiagnosis', 'moved')):
            try:
                if 'bugbug-reopened' in self.original_labels:
                    self.add_bugbug_tracking_label('bugbug-valid')
            except HTTPError as e:
                msg_log(f'bugbug-valid labeling failed ({e})', self.number)
                return oops()
            else:
                return make_response('bugbug-valid label added', 200)
        elif (self.action == 'unlabeled' and scope == 'public' and
              self.labeled_with == 'bugbug-probability-high'):
            try:
                self.add_bugbug_tracking_label('bugbug-reopened')
            except HTTPError as e:
                msg_log(f'bugbug-reopen labeling failed ({e})', self.number)
                return oops()
            else:
                return make_response('bugbug-reopen label added', 200)
        elif self.action == 'opened' and scope == 'private':
            # webcompat-bot needs to comment public URL of the issue
            # and we try to classify the issue using bugbug
            try:
                self.comment_public_uri()
            except HTTPError as e:
                msg_log(f'comment failed ({e})', self.number)
                return oops()

            try:
                self.label_nsfw()
            except HTTPError as e:
                msg_log(f'labeling as nsfw failed ({e})', self.number)
                return oops()

            try:
                self.classify()
            except (HTTPError, ConnectionError) as e:
                msg_log(f'classification failed ({e})', self.number)
                return oops()

            return make_response('public url added and issue classified', 200)
        elif (self.action == 'milestoned' and scope == 'private' and
              self.milestoned_with == 'accepted'):
            # private issue have been moderated and we will make it public
            try:
                self.moderate_private_issue()
            except HTTPError as e:
                msg_log('private:moving to public failed', self.number)
                return oops()
            else:
                # we didn't get exceptions, so it's safe to close it
                self.close_private_issue()
                return make_response('Moderated issue accepted', 200)
        elif (self.action == 'milestoned' and scope == 'private' and
              self.milestoned_with == 'accepted: incomplete'):
            # The private issue has been set to the "accepted: incomplete"
            # milestone. This will close the public and private issues, and
            # leave a message for the incomplete issue.
            try:
                self.close_public_issue(reason='incomplete')
            except HTTPError as e:
                msg_log(
                    'private:closing public issue as incomplete failed',
                    self.number)
                return oops()
            else:
                # we didn't get exceptions, so it's safe to comment why
                # it was closed as incomplete, and close it.
                self.comment_closed_reason(reason='incomplete')
                self.close_private_issue()
                return make_response('Moderated issue closed as incomplete',
                                     200)
        elif (self.action == 'milestoned' and scope == 'private' and
              self.milestoned_with == 'accepted: invalid'):
            # The private issue has been set to the "accepted: invalid"
            # milestone. This will close the public and private issues, and
            # leave a message for the invalid issue.
            try:
                self.close_public_issue(reason='invalid')
            except HTTPError as e:
                msg_log(
                    'private:closing public issue as invalid failed',
                    self.number)
                return oops()
            else:
                # we didn't get exceptions, so it's safe to comment why
                # it was closed as invalid, and close it.
                self.comment_closed_reason(reason='invalid')
                self.close_private_issue()
                return make_response('Moderated issue closed as invalid', 200)
        elif (self.action == 'milestoned' and scope == 'private' and
              self.milestoned_with == 'ml-autoclosed'):
            # The private issue has been automatically moved to ml-autoclosed
            # milestone. This will close the public and private issues, and
            # will replace the content of the public issue with placeholder.
            try:
                self.close_public_issue(reason='autoclosed')
            except HTTPError as e:
                msg_log(
                    'private:closing public issue as invalid by ml-bot failed',
                    self.number)
                return oops()
            else:
                # we didn't get exceptions, so it's safe to
                # close the private issue.
                self.close_private_issue()
                return make_response('Issue closed as invalid by ml bot', 200)
        elif (scope == 'private' and self.action == 'closed' and
              self.milestone == 'unmoderated'):
            # The private issue has been closed. It is rejected and the
            # private and public issues will be closed with a rejected message.
            try:
                self.close_public_issue(reason='rejected')
            except HTTPError as e:
                msg_log('public rejection failed', self.number)
                return oops()
            else:
                # we didn't get exceptions, so it's safe to close it
                self.close_private_issue()
                return make_response('Moderated issue rejected', 200)
        else:
            return make_response('Not an interesting hook', 403)
