# Copyright 2021-2022 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 views."""
import secrets
from unittest import mock

from django.db.models import Max
from django.http import HttpRequest, HttpResponse
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.datetime_safe import datetime

import pytz

from rest_framework import status
from rest_framework.response import Response

from debusine.db.models import Token, WorkRequest, Worker
from debusine.server.serializers import WorkRequestSerializer
from debusine.server.views import (
    GetNextWorkRequestView,
    IsTokenAuthenticated,
    IsWorkerAuthenticated,
    UpdateWorkRequestAsCompletedView,
    UpdateWorkerDynamicMetadataView,
    WorkRequestView,
)
from debusine.test import TestHelpersMixin


class RegisterViewTests(TestHelpersMixin, TestCase):
    """Tests for the RegisterView class."""

    def test_create_token_and_worker(self):
        """Token and Worker are created by the view."""
        self.assertQuerysetEqual(Token.objects.all(), [])
        self.assertQuerysetEqual(Worker.objects.all(), [])

        time_start = timezone.now()

        token_key = secrets.token_hex(32)

        data = {'token': token_key, 'fqdn': 'worker-bee.lan'}

        response = self.client.post(reverse('api:register'), data)

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

        token = Token.objects.get(key=token_key)

        worker = token.worker

        # Assert created worker
        self.assertEqual(worker.name, 'worker-bee-lan')
        self.assertGreaterEqual(worker.registered_at, time_start)
        self.assertLessEqual(worker.registered_at, timezone.now())
        self.assertIsNone(worker.connected_at)

        # Assert created token
        self.assertGreaterEqual(token.created_at, time_start)
        self.assertLessEqual(token.created_at, timezone.now())
        self.assertEqual(token.owner, '')
        self.assertEqual(token.comment, '')

    def test_create_token_and_worker_duplicate_name(self):
        """Token is created and Worker disambiguated if needed."""
        token_1 = Token()
        token_1.save()

        Worker.objects.create_with_fqdn('worker-lan', token_1)

        token_key = secrets.token_hex(32)
        data = {'token': token_key, 'fqdn': 'worker.lan'}
        response = self.client.post(reverse('api:register'), data)

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

        self.assertTrue(Worker.objects.filter(name='worker-lan-2').exists())

    def test_invalid_data(self):
        """Request is refused if we have bad data."""
        data = {
            'token': secrets.token_hex(128),  # Too long
            'fqdn': 'worker.lan',
        }

        response = self.client.post(reverse('api:register'), data)

        self.assertResponseProblem(response, "Cannot deserialize worker")
        self.assertIsInstance(response.data["validation_errors"], dict)

        self.assertFalse(Worker.objects.filter(name='worker-lan').exists())


class WorkRequestMixin:
    """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."""

        def _timestamp(ts):
            if ts is None:
                return None
            return ts.isoformat().replace("+00:00", "Z")

        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_data': work_request.task_data,
                'status': work_request.status,
                'result': work_request.result,
            },
        )


class GetNextWorkRequestViewTests(TestCase, WorkRequestMixin, TestHelpersMixin):
    """Tests for WorkRequestView."""

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

    def create_work_request(self, status, created_at):
        """Return a new work_request as specified by the parameters."""
        task_data = {'to_be_written': 'something', 'architecture': 'test'}

        work_request = WorkRequest.objects.create(
            worker=self.worker,
            task_name="sbuild",
            task_data=task_data,
            status=status,
        )

        work_request.created_at = created_at

        work_request.save()

        return work_request

    def test_check_permissions(self):
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            GetNextWorkRequestView.permission_classes,
        )

    def test_get_running_work_request(self):
        """Assert worker can get a WorkRequest."""
        # Create WorkRequest pending
        self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, pytz.UTC),
        )

        # Create WorkRequest running
        work_request_running = self.create_work_request(
            WorkRequest.Statuses.RUNNING,
            datetime(2022, 1, 5, 11, 14, 22, 242178, pytz.UTC),
        )

        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

        # Assert that we got the WorkRequest running
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request(response, work_request_running)

    def test_get_work_request_change_status(self):
        """Get a WorkRequest change the status to running."""
        work_request = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, pytz.UTC),
        )

        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

        # Assert that we got the WorkRequest running
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.check_response_for_work_request(response, work_request)

        work_request.refresh_from_db()

        self.assertEqual(work_request.status, WorkRequest.Statuses.RUNNING)

    def test_get_older_pending(self):
        """View return the older pending work request."""
        # Create WorkRequest pending
        work_request_pending_01 = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 20, 204242, pytz.UTC),
        )

        work_request_pending_02 = self.create_work_request(
            WorkRequest.Statuses.PENDING,
            datetime(2022, 1, 5, 10, 13, 22, 204242, pytz.UTC),
        )
        # Request
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

        self.check_response_for_work_request(response, work_request_pending_01)

        # work_request_pending_01 status changed. Let's move it back to
        # pending. This is not an allowed transition but helps here to test
        # that clients will receive the older pending
        work_request_pending_01.status = WorkRequest.Statuses.PENDING

        # Change created_at to force a change in the order that debusine
        # sends the WorkRequests to the client
        work_request_pending_01.created_at = datetime(
            2022, 1, 5, 10, 13, 24, 204242, pytz.UTC
        )

        work_request_pending_01.save()

        # New request to check the new order
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

        self.check_response_for_work_request(response, work_request_pending_02)

    def test_get_no_work_request(self):
        """Assert debusine sends HTTP 204 when nothing assigned to Worker."""
        response = self.client.get(
            reverse('api:work-request-get-next'),
            HTTP_TOKEN=self.worker.token.key,
        )

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

    def test_authentication_credentials_not_provided(self):
        """Assert Token is required to use the endpoint."""
        # This is to double-check that IsWorkerAuthenticated (inclusion
        # is verified in test_check_permissions) is returning what it should do.
        # All the views with IsWorkerAuthenticated behave the same way
        # IsWorkerAuthenticated is tested in IsWorkerAuthenticatedTests
        response = self.client.get(reverse('api:work-request-get-next'))
        self.assertEqual(
            response.json(),
            {'detail': 'Authentication credentials were not provided.'},
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)


class WorkRequestViewTests(TestHelpersMixin, TestCase, WorkRequestMixin):
    """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()
        )

        self.work_request_01 = WorkRequest.objects.create(
            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, pytz.UTC
        )
        self.work_request_01.save()

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

    def post_work_request_create(self, work_request_serialized) -> Response:
        """Post work_request to the endpoint api:work-request-create."""
        response = self.client.post(
            reverse('api:work-request-create'),
            data=work_request_serialized,
            HTTP_TOKEN=self.token.key,
            content_type="application/json",
        )

        return response

    def test_check_permissions(self):
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsTokenAuthenticated,
            WorkRequestView.permission_classes,
        )

    def test_get_work_request_success(self):
        """Return HTTP 200 and the correct information for the WorkRequest."""
        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."""
        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 test_post_success(self):
        """Client POST a WorkRequest and it gets created."""

        def mark_running():
            work_request = WorkRequest.objects.latest('created_at')
            work_request.assign_worker(self.worker_01)
            work_request.mark_running()

        patcher = mock.patch('debusine.server.views.schedule')
        schedule_mock = patcher.start()
        # Patching and using schedule_mock.side_effect to avoid the
        # complexity in this test of using a real worker to let the real
        # schedule() to assign the WorkRequest to the worker
        schedule_mock.side_effect = mark_running
        self.addCleanup(patcher.stop)

        work_request_serialized = {
            'task_name': 'sbuild',
            'task_data': {'foo': 'bar'},
        }
        response = self.post_work_request_create(work_request_serialized)
        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.assertDictContainsSubset(response.json(), work_request_serialized)

        # WorkRequest has the worker assigned because the mocked schedule()
        # was called (it is assigned and it's running)
        self.assertEqual(response.json()['worker'], self.worker_01.id)
        self.assertEqual(
            response.json()['status'], WorkRequest.Statuses.RUNNING
        )

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

    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_request_create(work_request_serialized)

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

    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_request_create(work_request_serialized)

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


class UpdateWorkRequestAsCompletedTests(TestCase, TestHelpersMixin):
    """Tests for UpdateWorkRequestAsCompleted class."""

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

        task_data = {'to_be_written': 'something', 'architecture': 'test'}

        self.work_request = WorkRequest.objects.create(
            worker=self.worker,
            task_name='sbuild',
            task_data=task_data,
            status=WorkRequest.Statuses.PENDING,
        )

    def test_check_permissions(self):
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            UpdateWorkRequestAsCompletedView.permission_classes,
        )

    def put_api_work_requested_completed(self, result):
        """Assert Worker can update WorkRequest result to completed-success."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.save()

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': self.work_request.id},
            ),
            data={"result": result},
            HTTP_TOKEN=self.worker.token.key,
            content_type="application/json",
        )

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

        self.work_request.refresh_from_db()
        self.assertEqual(
            self.work_request.status, WorkRequest.Statuses.COMPLETED
        )
        self.assertEqual(self.work_request.result, result)

    def test_update_completed_result_is_success(self):
        """Assert Worker can update WorkRequest result to completed-success."""
        self.put_api_work_requested_completed(WorkRequest.Results.SUCCESS)

    def test_update_completed_result_is_failure(self):
        """Assert Worker can update WorkRequest result to completed-error."""
        self.put_api_work_requested_completed(WorkRequest.Results.ERROR)

    def test_update_work_request_id_not_found(self):
        """Assert API returns HTTP 404 for a non-existing WorkRequest id."""
        max_id = WorkRequest.objects.aggregate(Max('id'))['id__max']

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': max_id + 1},
            ),
            data={"result": WorkRequest.Results.SUCCESS},
            HTTP_TOKEN=self.worker.token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "Work request not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_update_failure_invalid_result(self):
        """Assert Worker get HTTP 400 and error for invalid result field."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.save()

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': self.work_request.id},
            ),
            data={"result": "something not recognised"},
            HTTP_TOKEN=self.worker.token.key,
            content_type="application/json",
        )

        self.assertResponseProblem(
            response, "Cannot change work request as completed"
        )
        self.assertIsInstance(response.data["validation_errors"], dict)

    def test_unauthorized(self):
        """Assert worker cannot modify a task of another worker."""
        another_worker = Worker.objects.create_with_fqdn(
            "another-worker-test", self.create_token_enabled()
        )

        task_data = {'to_be_written': 'something', 'architecture': 'test'}

        another_work_request = WorkRequest.objects.create(
            worker=another_worker,
            task_name='sbuild',
            task_data=task_data,
            status=WorkRequest.Statuses.PENDING,
        )

        response = self.client.put(
            reverse(
                'api:work-request-completed',
                kwargs={'work_request_id': another_work_request.id},
            ),
            HTTP_TOKEN=self.worker.token.key,
        )

        self.assertResponseProblem(
            response,
            "Invalid worker to update the work request",
            status_code=status.HTTP_401_UNAUTHORIZED,
        )


class UpdateWorkerDynamicMetadataTests(TestCase, TestHelpersMixin):
    """Tests for DynamicMetadata."""

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

    def test_check_permissions(self):
        """Only authenticated requests are processed by the view."""
        self.assertIn(
            IsWorkerAuthenticated,
            UpdateWorkerDynamicMetadataView.permission_classes,
        )

    def test_update_metadata_success(self):
        """Worker's dynamic_metadata is updated."""
        self.assertEqual(self.worker_01.dynamic_metadata, {})
        self.assertEqual(self.worker_02.dynamic_metadata, {})

        metadata = {"cpu_cores": 4, "ram": 16}
        response = self.client.put(
            reverse('api:worker-dynamic-metadata'),
            data=metadata,
            HTTP_TOKEN=self.worker_01.token.key,
            content_type="application/json",
        )

        self.worker_01.refresh_from_db()

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

        self.assertEqual(self.worker_01.dynamic_metadata, metadata)
        self.assertLessEqual(
            self.worker_01.dynamic_metadata_updated_at, timezone.now()
        )

        self.assertEqual(self.worker_02.dynamic_metadata, {})
        self.assertIsNone(self.worker_02.dynamic_metadata_updated_at)


class IsWorkerAuthenticatedTests(TestCase, TestHelpersMixin):
    """Tests for IsWorkerAuthenticated."""

    def setUp(self):
        """Set up common objects."""
        self.is_worker_authenticated = IsWorkerAuthenticated()
        self.request = HttpRequest()

    def test_request_with_valid_token_worker_yes_permission(self):
        """IsWorkerAuthenticated.has_permission() return True: valid token."""
        token = self.create_token_enabled()
        Worker.objects.create_with_fqdn('worker.lan', token)

        self.request.META['HTTP_TOKEN'] = token.key

        self.assertTrue(
            self.is_worker_authenticated.has_permission(self.request, None)
        )

    def test_request_without_token_no_permission(self):
        """IsWorkerAuthenticated.has_permission() return False: no token."""
        self.assertFalse(
            self.is_worker_authenticated.has_permission(self.request, None)
        )

    def test_request_with_non_existing_token_no_permission(self):
        """
        IsWorkerAuthenticated.has_permission() return False.

        The token is invalid.
        """
        self.request.META['HTTP_TOKEN'] = 'a-token-that-does-not-exist'
        self.assertFalse(
            self.is_worker_authenticated.has_permission(self.request, None)
        )

    def test_request_with_token_no_associated_worker_no_permission(self):
        """IsWorkerAuthenticated.has_permission() return False: no worker."""
        token = self.create_token_enabled()
        self.request.META['HTTP_TOKEN'] = token.key
        self.assertFalse(
            self.is_worker_authenticated.has_permission(self.request, None)
        )


class IsTokenAuthenticatedTests(TestCase, TestHelpersMixin):
    """Tests for IsTokenAuthenticated."""

    def setUp(self):
        """Set up common objects."""
        self.is_token_authenticated = IsTokenAuthenticated()
        self.request = HttpRequest()

    def test_request_with_valid_token_worker_yes_permission(self):
        """IsTokenAuthenticated.has_permission() return True: valid token."""
        token = self.create_token_enabled()
        Worker.objects.create_with_fqdn('worker.lan', token)

        self.request.META['HTTP_TOKEN'] = token.key

        self.assertTrue(
            self.is_token_authenticated.has_permission(self.request, None)
        )

    def test_request_without_token_no_permission(self):
        """IsTokenAuthenticated.has_permission() return False: no token."""
        self.assertFalse(
            self.is_token_authenticated.has_permission(self.request, None)
        )

    def test_request_with_non_existing_token_no_permission(self):
        """IsTokenAuthenticated.has_permission() return False: invalid token."""
        self.request.META['HTTP_TOKEN'] = 'a-token-that-does-not-exist'
        self.assertFalse(
            self.is_token_authenticated.has_permission(self.request, None)
        )

    def test_request_with_disabled_token_no_permission(self):
        """
        IsTokenAuthenticated.has_permission() return False.

        The token is disabled.
        """
        token = Token.objects.create()
        self.request.META['HTTP_TOKEN'] = token.key
        self.assertFalse(
            self.is_token_authenticated.has_permission(self.request, None)
        )
