"""Endpoints integrating with Github, Bitbucket, and other webhooks."""

import hashlib
import hmac
import json
import logging
import re

from django.shortcuts import get_object_or_404
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound, ParseError
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.views import APIView

from readthedocs.core.signals import (
    webhook_bitbucket,
    webhook_github,
    webhook_gitlab,
)
from readthedocs.core.views.hooks import (
    build_branches,
    build_external_version,
    deactivate_external_version,
    get_or_create_external_version,
    trigger_sync_versions,
)
from readthedocs.integrations.models import HttpExchange, Integration
from readthedocs.projects.models import Feature, Project

log = logging.getLogger(__name__)

GITHUB_EVENT_HEADER = 'HTTP_X_GITHUB_EVENT'
GITHUB_SIGNATURE_HEADER = 'HTTP_X_HUB_SIGNATURE'
GITHUB_PUSH = 'push'
GITHUB_PULL_REQUEST = 'pull_request'
GITHUB_PULL_REQUEST_OPENED = 'opened'
GITHUB_PULL_REQUEST_CLOSED = 'closed'
GITHUB_PULL_REQUEST_REOPENED = 'reopened'
GITHUB_PULL_REQUEST_SYNC = 'synchronize'
GITHUB_CREATE = 'create'
GITHUB_DELETE = 'delete'
GITLAB_MERGE_REQUEST = 'merge_request'
GITLAB_MERGE_REQUEST_CLOSE = 'close'
GITLAB_MERGE_REQUEST_MERGE = 'merge'
GITLAB_MERGE_REQUEST_OPEN = 'open'
GITLAB_MERGE_REQUEST_REOPEN = 'reopen'
GITLAB_MERGE_REQUEST_UPDATE = 'update'
GITLAB_TOKEN_HEADER = 'HTTP_X_GITLAB_TOKEN'
GITLAB_PUSH = 'push'
GITLAB_NULL_HASH = '0' * 40
GITLAB_TAG_PUSH = 'tag_push'
BITBUCKET_EVENT_HEADER = 'HTTP_X_EVENT_KEY'
BITBUCKET_PUSH = 'repo:push'


class WebhookMixin:

    """Base class for Webhook mixins."""

    permission_classes = (permissions.AllowAny,)
    renderer_classes = (JSONRenderer,)
    integration = None
    integration_type = None
    invalid_payload_msg = 'Payload not valid'

    def post(self, request, project_slug):
        """Set up webhook post view with request and project objects."""
        self.request = request
        # WARNING: this is a hack to allow us access to `request.body` later.
        # Due to a limitation of DRF, we can't access `request.body`
        # after accessing `request.data`.
        # By accessing `request.body` we are able to access `request.body` and
        # `request.data` later without any problem (mostly black magic).
        # See #4940 for more background.
        self.request.body  # noqa
        self.project = None
        self.data = self.get_data()
        try:
            self.project = self.get_project(slug=project_slug)
            if not Project.objects.is_active(self.project):
                resp = {'detail': 'This project is currently disabled'}
                return Response(resp, status=status.HTTP_406_NOT_ACCEPTABLE)
        except Project.DoesNotExist:
            raise NotFound('Project not found')
        if not self.is_payload_valid():
            log.warning(
                'Invalid payload for project: %s and integration: %s',
                project_slug, self.integration_type
            )
            return Response(
                {'detail': self.invalid_payload_msg},
                status=HTTP_400_BAD_REQUEST,
            )
        resp = self.handle_webhook()
        if resp is None:
            log.info('Unhandled webhook event')
            resp = {'detail': 'Unhandled webhook event'}
        return Response(resp)

    def get_project(self, **kwargs):
        return Project.objects.get(**kwargs)

    def finalize_response(self, req, *args, **kwargs):
        """If the project was set on POST, store an HTTP exchange."""
        resp = super().finalize_response(req, *args, **kwargs)
        if hasattr(self, 'project') and self.project:
            HttpExchange.objects.from_exchange(
                req,
                resp,
                related_object=self.get_integration(),
                payload=self.data,
            )
        return resp

    def get_data(self):
        """
        Normalize posted data.

        This can be overridden to support multiples content types.
        """
        return self.request.data

    def handle_webhook(self):
        """Handle webhook payload."""
        raise NotImplementedError

    def get_external_version_data(self):
        """Get External Version data from payload."""
        raise NotImplementedError

    def is_payload_valid(self):
        """Validates the webhook's payload using the integration's secret."""
        return False

    def get_integration(self):
        """
        Get or create an inbound webhook to track webhook requests.

        We shouldn't need this, but to support legacy webhooks, we can't assume
        that a webhook has ever been created on our side. Most providers don't
        pass the webhook ID in either, so we default to just finding *any*
        integration from the provider. This is not ideal, but the
        :py:class:`WebhookView` view solves this by performing a lookup on the
        integration instead of guessing.
        """
        # `integration` can be passed in as an argument to `as_view`, as it is
        # in `WebhookView`
        if self.integration is not None:
            return self.integration
        try:
            integration = Integration.objects.get(
                project=self.project,
                integration_type=self.integration_type,
            )
        except Integration.DoesNotExist:
            integration = Integration.objects.create(
                project=self.project,
                integration_type=self.integration_type,
                # If we didn't create the integration,
                # we didn't set a secret.
                secret=None,
            )
        return integration

    def get_response_push(self, project, branches):
        """
        Build branches on push events and return API response.

        Return a JSON response with the following::

            {
                "build_triggered": true,
                "project": "project_name",
                "versions": [...]
            }

        :param project: Project instance
        :type project: Project
        :param branches: List of branch names to build
        :type branches: list(str)
        """
        to_build, not_building = build_branches(project, branches)
        if not_building:
            log.info(
                'Skipping project branches: project=%s branches=%s',
                project,
                branches,
            )
        triggered = bool(to_build)
        return {
            'build_triggered': triggered,
            'project': project.slug,
            'versions': list(to_build),
        }

    def sync_versions_response(self, project, sync=True):
        """
        Trigger a sync and returns a response indicating if the build was triggered or not.

        If `sync` is False, the sync isn't triggered and a response indicating so is returned.
        """
        version = None
        if sync:
            version = trigger_sync_versions(project)
        return {
            'build_triggered': False,
            'project': project.slug,
            'versions': [version] if version else [],
            'versions_synced': version is not None,
        }

    def get_external_version_response(self, project):
        """
        Trigger builds for External versions on pull/merge request events and return API response.

        Return a JSON response with the following::

            {
                "build_triggered": true,
                "project": "project_name",
                "versions": [verbose_name]
            }

        :param project: Project instance
        :type project: readthedocs.projects.models.Project
        """
        identifier, verbose_name = self.get_external_version_data()
        # create or get external version object using `verbose_name`.
        external_version = get_or_create_external_version(
            project, identifier, verbose_name
        )
        # returns external version verbose_name (pull/merge request number)
        to_build = build_external_version(
            project=project, version=external_version, commit=identifier
        )

        return {
            'build_triggered': True,
            'project': project.slug,
            'versions': [to_build],
        }

    def get_deactivated_external_version_response(self, project):
        """
        Deactivate the external version on merge/close events and return the API response.

        Return a JSON response with the following::

            {
                "version_deactivated": true,
                "project": "project_name",
                "versions": [verbose_name]
            }

        :param project: Project instance
        :type project: Project
        """
        identifier, verbose_name = self.get_external_version_data()
        deactivated_version = deactivate_external_version(
            project, identifier, verbose_name
        )
        return {
            'version_deactivated': bool(deactivated_version),
            'project': project.slug,
            'versions': [deactivated_version] if deactivated_version else [],
        }


class GitHubWebhookView(WebhookMixin, APIView):

    """
    Webhook consumer for GitHub.

    Accepts webhook events from GitHub, 'push' and 'pull_request' events trigger builds.
    Expects the webhook event type will be included in HTTP header ``X-GitHub-Event``,
    and we will have a JSON payload.

    Expects the following JSON::

        For push, create, delete Events:
            {
                "ref": "branch-name",
                ...
            }

        For pull_request Events:
            {
                "action": "opened",
                "number": 2,
                "pull_request": {
                    "head": {
                        "sha": "ec26de721c3235aad62de7213c562f8c821"
                    }
                }
            }

    See full payload here:

    - https://developer.github.com/v3/activity/events/types/#pushevent
    - https://developer.github.com/v3/activity/events/types/#createevent
    - https://developer.github.com/v3/activity/events/types/#deleteevent
    - https://developer.github.com/v3/activity/events/types/#pullrequestevent
    """

    integration_type = Integration.GITHUB_WEBHOOK
    invalid_payload_msg = 'Payload not valid, invalid or missing signature'

    def get_data(self):
        if self.request.content_type == 'application/x-www-form-urlencoded':
            try:
                return json.loads(self.request.data['payload'])
            except (ValueError, KeyError):
                pass
        return super().get_data()

    def get_external_version_data(self):
        """Get Commit Sha and pull request number from payload."""
        try:
            identifier = self.data['pull_request']['head']['sha']
            verbose_name = str(self.data['number'])

            return identifier, verbose_name

        except KeyError:
            raise ParseError('Parameters "sha" and "number" are required')

    def is_payload_valid(self):
        """
        GitHub use a HMAC hexdigest hash to sign the payload.

        It is sent in the request's header.

        See https://developer.github.com/webhooks/securing/.
        """
        signature = self.request.META.get(GITHUB_SIGNATURE_HEADER)
        secret = self.get_integration().secret
        if not secret:
            log.info(
                'Skipping payload signature validation. project=%s',
                self.project.slug,
            )
            return True
        if not signature:
            return False
        msg = self.request.body.decode()
        digest = GitHubWebhookView.get_digest(secret, msg)
        result = hmac.compare_digest(
            b'sha1=' + digest.encode(),
            signature.encode(),
        )
        return result

    @staticmethod
    def get_digest(secret, msg):
        """Get a HMAC digest of `msg` using `secret`."""
        digest = hmac.new(
            secret.encode(),
            msg=msg.encode(),
            digestmod=hashlib.sha1,
        )
        return digest.hexdigest()

    def handle_webhook(self):
        """
        Handle GitHub webhook events.

        It checks for all the events we support currently:

        - PUSH: Triggered on a push to a repository branch. Branch pushes and repository tag pushes
          also trigger webhook push events.

          .. note::

            ``created`` and ``deleted`` indicate if the push was a branch/tag created or deleted.
            This is required for old webhook created at Read the Docs that do not register the
            ``create`` and ``delete`` events.

            Newer webhooks created on Read the Docs, will trigger a PUSH+created=True **and** a
            CREATE event. We need to handle this in a specific way to not trigger the sync twice.

        - CREATE: Represents a created branch or tag.

        - DELETE: Represents a deleted branch or tag.

        - PULL_REQUEST: Triggered when a pull request is assigned, unassigned, labeled, unlabeled,
          opened, edited, closed, reopened, synchronize, ready_for_review, locked, unlocked or when
          a pull request review is requested or removed (``action`` will contain this data)

        See https://developer.github.com/v3/activity/events/types/

        """
        # Get event and trigger other webhook events
        action = self.data.get('action', None)
        created = self.data.get('created', False)
        deleted = self.data.get('deleted', False)
        event = self.request.META.get(GITHUB_EVENT_HEADER, GITHUB_PUSH)
        webhook_github.send(
            Project,
            project=self.project,
            data=self.data,
            event=event,
        )

        # Sync versions when a branch/tag was created/deleted
        if event in (GITHUB_CREATE, GITHUB_DELETE):
            log.info('Triggered sync_versions: project=%s event=%s', self.project, event)
            return self.sync_versions_response(self.project)

        # Handle pull request events
        if self.project.external_builds_enabled and event == GITHUB_PULL_REQUEST:
            if (
                action in
                [
                    GITHUB_PULL_REQUEST_OPENED,
                    GITHUB_PULL_REQUEST_REOPENED,