# 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.

"""Tests for the work request views."""

from base64 import b64encode
from datetime import datetime
from datetime import timezone as tz
from typing import Any, ClassVar
from unittest import mock

from django.contrib.auth import get_user_model
from django.db.models import Max
from django.http import HttpResponse
from django.http.response import HttpResponseBase
from django.test import override_settings
from django.urls import reverse
from django.utils.http import urlencode

from rest_framework import status

from debusine.db.models import (
    DEFAULT_WORKSPACE_NAME,
    FileStore,
    Token,
    WorkRequest,
    Worker,
    Workspace,
)
from debusine.server.serializers import (
    WorkRequestSerializer,
)
from debusine.server.views.work_requests import (
    WorkRequestPagination,
)
from debusine.tasks.models import TaskTypes
from debusine.test.django import JSONResponseProtocol, TestCase
from debusine.test.utils import date_time_to_isoformat_rest_framework


class WorkRequestTestCase(TestCase):
    """Helper methods for tests with WorkRequest as response."""

    def check_response_for_work_request(
        self, response: HttpResponse, work_request: WorkRequest
    ):
        """Ensure the json data corresponds to the supplied work request."""
        assert isinstance(response, JSONResponseProtocol)

        def _timestamp(ts: datetime | None) -> str | None:
            if ts is None:
                return None
            return date_time_to_isoformat_rest_framework(ts)

        artifact_ids = list(
            work_request.artifact_set.all()
            .order_by("id")
            .values_list("id", flat=True)
        )

        self.assertEqual(
            response.json(),
            {
                'id': work_request.pk,
                'task_name': work_request.task_name,
                'created_at': _timestamp(work_request.created_at),
                'started_at': _timestamp(work_request.started_at),
                'completed_at': _timestamp(work_request.completed_at),
                'duration': work_request.duration,
                'worker': work_request.worker_id,
                'task_type': TaskTypes.WORKER,
                'task_data': work_request.task_data,
                'dynamic_task_data': None,
                'priority_base': work_request.priority_base,
                'priority_adjustment': work_request.priority_adjustment,
                'event_reactions': work_request.event_reactions_json,
                'status': work_request.status,
                'result': work_request.result,
                'artifacts': artifact_ids,
                'workspace': DEFAULT_WORKSPACE_NAME,
                'created_by': work_request.created_by.id,
            },
        )

    def check_response_for_work_request_list(
        self,
        response: HttpResponse,
        work_requests: list[WorkRequest],
        start: int = 0,
        page_size: int | None = None,
    ):
        """Ensure the json data corresponds to this list of work requests."""
        assert isinstance(response, JSONResponseProtocol)

        def _timestamp(ts: datetime | None) -> str | None:
            if ts is None:
                return None
            return date_time_to_isoformat_rest_framework(ts)

        def _encode_cursor(
            *, reverse: bool = False, position: str | None = None
        ) -> str:
            tokens: dict[str, int | str] = {}
            if reverse:
                tokens['r'] = 1
            if position is not None:  # pragma: no cover
                tokens['p'] = position
            querystring = urlencode(tokens, doseq=True)
            return b64encode(querystring.encode('ascii')).decode('ascii')

        next_url = None
        previous_url = None
        if page_size is not None:
            if start + page_size < len(work_requests):
                next_cursor = _encode_cursor(
                    position=str(
                        work_requests[start + page_size - 1].created_at
                    )
                )
                next_url = (
                    'http://testserver'
                    + reverse('api:work-requests')
                    + '?'
                    + urlencode({'cursor': next_cursor})
                )
            if start > 0:
                previous_cursor = _encode_cursor(
                    position=str(work_requests[start].created_at),
                    reverse=True,
                )
                previous_url = (
                    'http://testserver'
                    + reverse('api:work-requests')
                    + '?'
                    + urlencode({'cursor': previous_cursor})
                )
            work_requests = work_requests[start : start + page_size]

        self.assertEqual(
            response.json(),
            {
                'next': next_url,
                'previous': previous_url,
                'results': [
                    {
                        'id': work_request.pk,
                        'task_name': work_request.task_name,
                        'created_at': _timestamp(work_request.created_at),
                        'started_at': _timestamp(work_request.started_at),
                        'completed_at': _timestamp(work_request.completed_at),
                        'duration': work_request.duration,
                        'worker': work_request.worker_id,
                        'task_type': TaskTypes.WORKER,
                        'task_data': work_request.task_data,
                        'dynamic_task_data': None,
                        'priority_base': work_request.priority_base,
                        'priority_adjustment': work_request.priority_adjustment,
                        'event_reactions': work_request.event_reactions_json,
                        'status': work_request.status,
                        'result': work_request.result,
                        'artifacts': list(
                            work_request.artifact_set.all()
                            .order_by("id")
                            .values_list("id", flat=True)
                        ),
                        'workspace': DEFAULT_WORKSPACE_NAME,
                        'created_by': work_request.created_by.id,
                    }
                    for work_request in work_requests
                ],
            },
        )


class WorkRequestViewTests(WorkRequestTestCase):
    """Tests for WorkRequestView class."""

    def setUp(self):
        """Set up common data."""
        self.worker_01 = Worker.objects.create_with_fqdn(
            "worker-01", self.create_token_enabled()
        )

        # This token would be used by a debusine client using the API
        self.token = self.create_token_enabled(with_user=True)

    def create_test_work_request(self):
        """Create a work request for testing."""
        self.work_request_01 = self.create_work_request(
            worker=self.worker_01,
            task_name='sbuild',
            task_data={'architecture': 'amd64'},
            status=WorkRequest.Statuses.PENDING,
        )
        self.work_request_01.created_at = datetime(
            2022, 2, 22, 16, 31, 24, 242244, tz.utc
        )
        self.work_request_01.save()

    def create_superuser_token(self):
        """Create a token for a superuser."""
        user = get_user_model().objects.create_superuser(
            username="testsuperuser",
            email="superuser@mail.none",
            password="testsuperpass",
        )
        return self.create_token_enabled(user=user)

    def post_work_requests(
        self,
        work_request_serialized: dict[str, Any],
        token: Token | None = None,
    ) -> HttpResponseBase:
        """Post work_request to the endpoint api:work-requests."""
        if token is None:
            token = self.token

        response = self.client.post(
            reverse('api:work-requests'),
            data=work_request_serialized,
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        return response

    def test_check_permissions_get_token_must_be_enabled(self):
        """An enabled token is needed to request a WorkRequest."""
        token = self.create_token_enabled()
        token.disable()
        token.save()
        self.create_test_work_request()

        response = self.client.get(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            HTTP_TOKEN=token.key,
        )

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_check_permissions_post_token_must_have_user_associated(self):
        """The token must have a user associated for creating a WorkRequest."""
        token = self.create_token_enabled()

        work_request_serialized = {
            "task_name": "sbuild",
            "task_data": {"foo": "bar"},
        }

        response = self.client.post(
            reverse('api:work-requests'),
            data=work_request_serialized,
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_get_work_request_success(self):
        """Return HTTP 200 and the correct information for the WorkRequest."""
        self.create_test_work_request()

        response = self.client.get(
            reverse(
                'api:work-request-detail',
                kwargs={'work_request_id': self.work_request_01.id},
            ),
            HTTP_TOKEN=self.worker_01.token.key,
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request(response, self.work_request_01)

    def test_get_work_request_not_found(self):
        """Return HTTP 404 if the WorkRequest id does not exist."""
        self.create_test_work_request()

        max_id = WorkRequest.objects.aggregate(Max('id'))['id__max']
        response = self.client.get(
            reverse(
                'api:work-request-detail',
                kwargs={'work_request_id': max_id + 1},
            ),
            HTTP_TOKEN=self.worker_01.token.key,
        )

        self.assertEqual(
            response.json(), {'detail': 'Work request id not found'}
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def verify_post_success(self, *, workspace_name: str | None):
        """Client POST a WorkRequest and verifies it gets created."""
        work_request_serialized = {
            'task_name': 'sbuild',
            'task_data': {'foo': 'bar'},
        }

        if workspace_name is not None:
            Workspace.objects.create(
                name=workspace_name, default_file_store=FileStore.default()
            )

            work_request_serialized['workspace'] = workspace_name

        with self.captureOnCommitCallbacks(execute=True), mock.patch(
            "debusine.server.scheduler.schedule_task.delay"
        ) as mock_schedule_task_delay:
            response = self.post_work_requests(work_request_serialized)
        assert isinstance(response, JSONResponseProtocol)
        work_request = WorkRequest.objects.latest('created_at')

        # Response contains the serialized WorkRequest
        self.assertEqual(
            response.json(), WorkRequestSerializer(work_request).data
        )

        # Response contains fields that were posted
        self.assertDictContainsAll(response.json(), work_request_serialized)

        # A scheduler run was requested via Celery
        mock_schedule_task_delay.assert_called_once()

        expected_workspace_name = workspace_name or DEFAULT_WORKSPACE_NAME
        self.assertEqual(response.json()["workspace"], expected_workspace_name)

        self.assertEqual(response.status_code, status.HTTP_200_OK)

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=False)
    def test_post_success_workspace_testing(self):
        """Client POST a WorkRequest with a specific workspace."""
        self.verify_post_success(workspace_name="Testing")

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=False)
    def test_post_success_workspace_default(self):
        """Client POST a WorkRequest without specifying a workspace."""
        self.verify_post_success(workspace_name=None)

    @override_settings(DISABLE_AUTOMATIC_SCHEDULING=False)
    def test_post_success_workspace_default_superuser(self):
        """Client POST a WorkRequest without specifying a workspace."""
        self.token = self.create_superuser_token()
        self.verify_post_success(workspace_name=None)

    def test_post_task_name_invalid(self):
        """Client POST a WorkRequest its task_name is invalid."""
        work_request_serialized = {
            'task_name': 'bad-name',
            'task_data': {'foo': 'bar'},
        }
        response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(
            response,
            "Cannot create work request: task name is not registered",
            '^Task name: "bad-name". Registered task names: .*sbuild.*$',
        )

    def test_post_non_worker_task(self):
        """Client POST a WorkRequest without enough rights for that type."""
        work_request_serialized = {
            'task_name': 'servernoop',
            'task_data': {'foo': 'bar'},
        }
        response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(
            response,
            "Cannot create work request: task name is not registered",
            '^Task name: "servernoop"',
        )

    def test_post_non_worker_task_superuser(self):
        """Superusers can POST a server task."""
        token = self.create_superuser_token()

        work_request_serialized = {
            'task_name': 'servernoop',
            'task_data': {'foo': 'bar'},
        }
        response = self.post_work_requests(work_request_serialized, token=token)

        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_post_work_request_too_many_fields(self):
        """Post request with too many fields: returns HTTP 400 and error."""
        work_request_serialized = {
            'task_name': 'sbuild',
            'task_data': {'foo': 'bar'},
            'result': 'SUCCESS',
        }
        response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(response, "Cannot deserialize work request")
        self.assertIsInstance(response.json()["validation_errors"], dict)

    def test_post_work_request_invalid_channel(self):
        """Post request with an invalid channel: returns HTTP 400 and error."""
        channel = "non-existing"
        work_request_serialized = {
            "task_name": "sbuild",
            "event_reactions": {
                "on_failure": [
                    {"action": "send-notification", "channel": channel}
                ]
            },
        }
        response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(
            response, f"Non-existing channels: ['{channel}']"
        )

    def test_post_work_request_invalid_reaction_events(self):
        """Post request with an invalid channel: returns HTTP 400 and error."""
        work_request_serialized = {
            "task_name": "sbuild",
            "event_reactions": {"on_invalid": []},
        }
        response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(response, "Invalid event_reactions")

    def test_post_work_request_invalid_data(self):
        """Post request with an invalid data: returns HTTP 400 and error."""
        work_request_serialized = {
            "task_name": "sbuild",
            "event_reactions": {"on_failure": [{"channel": "my-changes"}]},
        }
        response = self.post_work_requests(work_request_serialized)

        self.assertResponseProblem(response, "Invalid event_reactions")

    def test_list_work_request_empty(self):
        """Return an empty list if there are no work requests."""
        response = self.client.get(
            reverse('api:work-requests'), HTTP_TOKEN=self.worker_01.token.key
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request_list(response, [])

    def test_list_work_request_one_page(self):
        """Return a single page if there are only a few work requests."""
        work_requests = []
        for _ in range(3):
            work_request = self.create_work_request(
                worker=self.worker_01,
                task_name='sbuild',
                task_data={'architecture': 'amd64'},
                status=WorkRequest.Statuses.PENDING,
            )
            work_request.save()
            work_requests.insert(0, work_request)

        with mock.patch.object(WorkRequestPagination, 'page_size', 5):
            response = self.client.get(
                reverse('api:work-requests'),
                HTTP_TOKEN=self.worker_01.token.key,
            )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request_list(response, work_requests)

    def test_list_work_request_multiple_pages(self):
        """Return multiple pages if there are many work requests."""
        work_requests = []
        for _ in range(10):
            work_request = self.create_work_request(
                worker=self.worker_01,
                task_name='sbuild',
                task_data={'architecture': 'amd64'},
                status=WorkRequest.Statuses.PENDING,
            )
            work_request.save()
            work_requests.insert(0, work_request)

        with mock.patch.object(WorkRequestPagination, 'page_size', 5):
            response = self.client.get(
                reverse('api:work-requests'),
                HTTP_TOKEN=self.worker_01.token.key,
            )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request_list(
            response, work_requests, start=0, page_size=5
        )

        with mock.patch.object(WorkRequestPagination, 'page_size', 5):
            response = self.client.get(
                response.json()['next'],
                HTTP_TOKEN=self.worker_01.token.key,
            )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request_list(
            response, work_requests, start=5, page_size=5
        )

    def test_patch_work_request_success(self):
        """Patch a work request and return its new representation."""
        self.create_test_work_request()
        token = self.create_token_enabled(with_user=True)
        self.add_user_permission(
            token.user, WorkRequest, "manage_workrequest_priorities"
        )

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": 100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.work_request_01.refresh_from_db()
        self.assertEqual(self.work_request_01.priority_adjustment, 100)
        self.check_response_for_work_request(response, self.work_request_01)

    def test_patch_work_request_empty(self):
        """An empty patch to a work request does nothing."""
        self.create_test_work_request()
        token = self.create_token_enabled(with_user=True)

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.work_request_01.refresh_from_db()
        self.assertEqual(self.work_request_01.priority_adjustment, 0)
        self.check_response_for_work_request(response, self.work_request_01)

    def test_patch_work_request_anonymous(self):
        """Anonymous users may not patch work requests."""
        self.create_test_work_request()

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": -100},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Authentication credentials were not provided.",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_patch_work_request_own_request_negative_adjustment(self):
        """Users can set negative adjustments on their own work requests."""
        self.create_test_work_request()
        token = self.create_token_enabled(user=self.work_request_01.created_by)

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": -100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.work_request_01.refresh_from_db()
        self.assertEqual(self.work_request_01.priority_adjustment, -100)
        self.check_response_for_work_request(response, self.work_request_01)

    def test_patch_work_request_own_request_positive_adjustment(self):
        """Users cannot set positive adjustments on their own work requests."""
        self.create_test_work_request()
        token = self.create_token_enabled(user=self.work_request_01.created_by)

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": 100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "You are not permitted to set priority adjustments",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_patch_work_request_without_permission(self):
        """Setting positive adjustments requires a special permission."""
        self.create_test_work_request()
        token = self.create_token_enabled(with_user=True)

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_adjustment": 100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "You are not permitted to set priority adjustments",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_patch_work_request_disallowed_property(self):
        """Only certain properties of work requests may be patched."""
        self.create_test_work_request()
        token = self.create_token_enabled(with_user=True)
        self.add_user_permission(
            token.user, WorkRequest, "manage_workrequest_priorities"
        )

        response = self.client.patch(
            reverse(
                "api:work-request-detail",
                kwargs={"work_request_id": self.work_request_01.id},
            ),
            data={"priority_base": 100},
            HTTP_TOKEN=token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response, "Error", detail_pattern="Invalid fields: priority_base"
        )


class WorkRequestRetryViewTests(TestCase):
    """Tests for WorkRequestRetryView class."""

    work_request: ClassVar[WorkRequest]
    token: ClassVar[Token]

    @classmethod
    def setUpTestData(cls):
        """Set up common data."""
        super().setUpTestData()
        cls.work_request = cls.create_work_request(task_name="noop")
        # This token would be used by a debusine client using the API
        cls.token = cls.create_token_enabled(with_user=True)

    def post(
        self,
        work_request_id: int | None = None,
        token: Token | None = None,
    ) -> HttpResponseBase:
        """Post work_request to the endpoint api:work-requests."""
        return self.client.post(
            reverse(
                'api:work-requests-retry',
                kwargs={
                    "work_request_id": (
                        self.work_request.id
                        if work_request_id is None
                        else work_request_id
                    )
                },
            ),
            data={},
            HTTP_TOKEN=(token or self.token).key,
            content_type="application/json",
        )

    def test_check_permissions_post_token_must_be_enabled(self):
        """An enabled token is needed to retry a WorkRequest."""
        self.token.disable()
        self.token.save()
        response = self.post(token=self.token)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_check_permissions_post_token_must_have_user_associated(self):
        """The token must have a user for retrying a WorkRequest."""
        self.token.user = None
        self.token.save()
        response = self.post(token=self.token)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_post_work_request_not_found(self):
        """Return HTTP 404 if the WorkRequest id does not exist."""
        max_id = WorkRequest.objects.aggregate(Max('id'))['id__max']
        response = self.post(work_request_id=max_id + 1)
        self.assertEqual(
            response.json(), {'detail': 'Work request id not found'}
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def test_post_cannot_retry(self):
        """Target work request cannot be retried."""
        self.assertFalse(self.work_request.can_retry())
        response = self.post()
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertResponseProblem(
            response,
            "Cannot retry work request",
            detail_pattern="Only aborted or failed tasks can be retried",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_post_success(self):
        """Client POST retries the WorkRequest and the new gets created."""
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.save()
        self.assertFalse(hasattr(self.work_request, "superseded"))
        self.assertTrue(self.work_request.can_retry())
        response = self.post()
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        self.work_request.refresh_from_db()
        self.assertTrue(hasattr(self.work_request, "superseded"))

        # Response contains the new WorkRequest
        self.assertEqual(
            response.json(),
            WorkRequestSerializer(self.work_request.superseded).data,
        )
