# Copyright 2021-2024 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Views for the server application: work requests."""

import itertools
import logging
from typing import Any

from django.db import transaction
from django.http import Http404
from django.http.response import HttpResponseBase

from rest_framework import status
from rest_framework.exceptions import NotFound
from rest_framework.pagination import CursorPagination
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView

from debusine.db.models import (
    DEFAULT_WORKSPACE_NAME,
    NotificationChannel,
    WorkRequest,
)
from debusine.db.models.work_requests import CannotRetry, CannotUnblock
from debusine.server.scheduler import TaskDatabase
from debusine.server.serializers import (
    WorkRequestSerializer,
    WorkRequestUnblockSerializer,
    WorkRequestUpdateSerializer,
)
from debusine.server.views.base import (
    GenericAPIViewBase,
    IsGet,
    IsTokenAuthenticated,
    IsTokenUserAuthenticated,
    IsTokenUserAuthenticatedDjangoModelPermissions,
    ListAPIViewBase,
    ProblemResponse,
    UpdateAPIViewBase,
)
from debusine.tasks import BaseTask
from debusine.tasks.models import ActionTypes, TaskTypes

logger = logging.getLogger(__name__)


class WorkRequestPagination(CursorPagination):
    """Pagination for lists of work requests."""

    ordering = "-created_at"


class WorkRequestView(
    ListAPIViewBase[WorkRequest], UpdateAPIViewBase[WorkRequest]
):
    """View used by the debusine client to get information of a WorkRequest."""

    permission_classes = [
        IsTokenUserAuthenticated | IsTokenAuthenticated & IsGet
    ]
    serializer_class = WorkRequestSerializer
    pagination_class = WorkRequestPagination
    queryset = WorkRequest.objects.all()
    lookup_url_kwarg = "work_request_id"

    def get(self, request, work_request_id: int | None = None):
        """Return status information for WorkRequest or not found."""
        if work_request_id is None:
            return super().get(request)

        try:
            work_request = WorkRequest.objects.get(pk=work_request_id)
        except WorkRequest.DoesNotExist:
            return Response(
                {'detail': 'Work request id not found'},
                status=status.HTTP_404_NOT_FOUND,
            )

        return Response(
            WorkRequestSerializer(work_request).data, status=status.HTTP_200_OK
        )

    @transaction.atomic
    def post(self, request):
        """Create a new work request."""
        data = request.data.copy()

        # "workspace" might not be in data, but it is a mandatory field
        # in the serializer. If not sent by the client use the default workspace
        if "workspace" not in data:
            data["workspace"] = DEFAULT_WORKSPACE_NAME

        data["created_by"] = request.token.user.id
        work_request_deserialized = WorkRequestSerializer(
            data=data,
            only_fields=[
                'task_name',
                'task_data',
                'event_reactions',
                'workspace',
                'created_by',
            ],
        )

        if not work_request_deserialized.is_valid():
            errors = work_request_deserialized.errors

            logger.debug(
                "Error creating work request. Could not be deserialized: %s",
                errors,
            )

            return ProblemResponse(
                "Cannot deserialize work request", validation_errors=errors
            )

        # only accept send-notification actions
        for event, reactions in work_request_deserialized.validated_data.get(
            "event_reactions_json", {}
        ).items():
            for action in reactions:
                if action["action"] != ActionTypes.SEND_NOTIFICATION:
                    return ProblemResponse(
                        "Invalid event_reactions",
                        detail=(
                            f"Action type {action['action']!r} is not allowed "
                            f"here"
                        ),
                    )

        if len(non_existing_channels := self._non_existing_channels(data)) > 0:
            return ProblemResponse(
                f"Non-existing channels: {sorted(non_existing_channels)}"
            )

        task_name = data['task_name']

        task_types = [TaskTypes.WORKER]
        if request.token.user.is_superuser:
            task_types.append(TaskTypes.SERVER)

        for task_type in task_types:
            if BaseTask.is_valid_task_name(task_type, task_name):
                break
        else:
            valid_task_names = ', '.join(
                itertools.chain(
                    *(
                        BaseTask.task_names(task_type)
                        for task_type in task_types
                    ),
                ),
            )
            logger.debug(
                "Error creating work request: task name is not registered: %s",
                task_name,
            )
            return ProblemResponse(
                "Cannot create work request: task name is not registered",
                detail=f'Task name: "{task_name}". '
                f"Registered task names: {valid_task_names}",
            )

        # Return an error if compute_dynamic_data() raise an exception
        # (otherwise it happens during the scheduler() and the user
        # doesn't know why)
        #
        # If more checks are added here: they might need to be added as well
        # in the view WorkRequestCreateView.form_valid()
        w = work_request_deserialized.save(task_type=task_type)

        try:
            w.get_task().compute_dynamic_data(TaskDatabase(w))
        except Exception as exc:
            transaction.set_rollback(True)
            return ProblemResponse(
                "Cannot create work request: error computing dynamic data",
                detail=f"Task data: {w.task_data} Error: {exc}",
            )

        w.refresh_from_db()

        return Response(
            WorkRequestSerializer(w).data, status=status.HTTP_200_OK
        )

    @staticmethod
    def _non_existing_channels(data: dict[str, Any]) -> set[str]:
        """Return non existing channels from event_reactions."""
        non_existing_channels: set[str] = set()

        for event, reactions in data.get("event_reactions", {}).items():
            for action in reactions:
                assert (
                    action["action"] == ActionTypes.SEND_NOTIFICATION
                )  # filtered above
                channel_name = action["channel"]
                try:
                    NotificationChannel.objects.get(name=channel_name)
                except NotificationChannel.DoesNotExist:
                    non_existing_channels.add(channel_name)

        return non_existing_channels

    # TODO: We can't declare the return type properly here because the
    # return type of UpdateAPIView.patch is rest_framework.response.Response
    # and ProblemResponse isn't a subtype of that.  It works anyway, but
    # needs to be fixed somehow.
    def patch(self, request: Request, *args: Any, **kwargs: Any):
        """Update properties of a work request."""
        # Only a subset of properties may be patched.
        work_request_deserialized = WorkRequestUpdateSerializer(
            data=request.data
        )
        work_request_deserialized.is_valid(raise_exception=True)

        # Only users with appropriate permissions may set priority
        # adjustments.
        priority_adjustment = work_request_deserialized.validated_data.get(
            "priority_adjustment"
        )
        if priority_adjustment is None:
            pass
        else:
            token = request.token
            # permission_classes is declared such that we won't get this far
            # unless the request has an enabled token with an associated
            # user.
            assert token is not None
            assert token.user is not None
            # Users with manage_workrequest_priorities have full access.
            if token.user.has_perm("db.manage_workrequest_priorities"):
                pass
            # Users can set non-positive priority adjustments on their own
            # work requests.
            elif (
                priority_adjustment <= 0
                and token.user == self.get_object().created_by
            ):
                pass
            else:
                return ProblemResponse(
                    "You are not permitted to set priority adjustments",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

        return super().patch(request, *args, **kwargs)


class WorkRequestRetryView(APIView):
    """View used by the debusine client to get information of a WorkRequest."""

    permission_classes = [IsTokenUserAuthenticated]

    def post(self, *args, **kwargs):
        """Retry a work request."""
        try:
            work_request = WorkRequest.objects.get(
                pk=self.kwargs["work_request_id"]
            )
        except WorkRequest.DoesNotExist:
            return Response(
                {'detail': 'Work request id not found'},
                status=status.HTTP_404_NOT_FOUND,
            )

        try:
            new_work_request = work_request.retry()
        except CannotRetry as e:
            return ProblemResponse(
                "Cannot retry work request",
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=str(e),
            )

        return Response(
            WorkRequestSerializer(new_work_request).data,
            status=status.HTTP_200_OK,
        )


class WorkRequestUnblockViewPermissions(
    IsTokenUserAuthenticatedDjangoModelPermissions
):
    """Custom permissions for :py:class:`WorkRequestUnblockView`."""

    perms_map = {
        **IsTokenUserAuthenticatedDjangoModelPermissions.perms_map,
        # By default DjangoModelPermissions assumes that POST is used to
        # create new objects, but in this case we're using it to change an
        # existing object.
        "POST": ["%(app_label)s.change_%(model_name)s"],
    }


class WorkRequestUnblockView(GenericAPIViewBase[WorkRequest]):
    """View used to unblock a work request awaiting manual approval."""

    # TODO: We should replace this with some finer-grained ACL once we have
    # that sort of thing.  It would be useful to grant the ability to edit
    # manual unblocks without being able to freely edit work requests, or
    # the ability to edit manual unblocks only in certain contexts.
    permission_classes = [WorkRequestUnblockViewPermissions]
    serializer_class = WorkRequestUnblockSerializer
    queryset = WorkRequest.objects
    lookup_url_kwarg = "work_request_id"

    def get_object(self) -> WorkRequest:
        """Override to return more API-friendly errors."""
        try:
            return super().get_object()
        except Http404 as exc:
            raise NotFound(str(exc))

    def post(
        self, request: Request, *args: Any, **kwargs: Any
    ) -> HttpResponseBase:
        """Edit a manual unblock."""
        work_request = self.get_object()
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        notes = serializer.validated_data.get("notes")
        action = serializer.validated_data.get("action")

        token = self.request.token
        # permission_classes is declared such that we won't get this far
        # unless the request has an enabled token with an associated user.
        assert token is not None
        assert token.user is not None

        try:
            work_request.review_manual_unblock(
                user=token.user, notes=notes, action=action
            )
        except CannotUnblock as e:
            return ProblemResponse(str(e))

        return Response(
            WorkRequestSerializer(work_request).data, status=status.HTTP_200_OK
        )
