# Copyright 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 debusine.tests.TestHelpersMixin."""

import json
import logging
import os
from configparser import ConfigParser
from datetime import timedelta
from typing import ClassVar

import requests
import responses
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.http.response import HttpResponse
from django.test import TestCase
from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response

from debusine.db.models import ArtifactRelation, FileStore, Scope, Token, User
from debusine.server.views import ProblemResponse
from debusine.test import TestHelpersMixin
from debusine.test.django import (
    AllowAll,
    ChannelsHelpersMixin,
    DenyAll,
    ListFilter,
    PermissionOverride,
    override_permission,
)


# TODO: coverage is confused by something here, possibly
# https://github.com/python/cpython/issues/106749
class TestChannelsHelpersMixinTests(
    ChannelsHelpersMixin, TestCase
):  # pragma: no cover
    """Tests for methods in ChannelsHelpersMixin."""

    # Default channel name to be used during the tests.
    channel_name = "generic-channel-for-testing"

    async def test_create_channel(self) -> None:
        """Create channel return a dictionary with layer and name keys."""
        channel = await self.create_channel("channel-test")

        self.assertEqual(channel.keys(), {"layer", "name"})

    async def test_assert_channel_received_raises_exception(self) -> None:
        """assert_channel_received raise exception: nothing was received."""
        channel = await self.create_channel(self.channel_name)
        with self.assertRaisesRegex(
            self.failureException,
            "^Expected '{'type': 'work_request'}' received nothing$",
        ):
            await self.assert_channel_received(
                channel, {"type": "work_request"}
            )

    async def test_assert_channel_received_raise_wrong_data(self) -> None:
        """assert_channel_received raise exception: unexpected data received."""
        channel = await self.create_channel(self.channel_name)
        message = {"type": "work_request.assigned"}
        await channel["layer"].group_send(
            self.channel_name, {"some other message": "values"}
        )

        with self.assertRaises(AssertionError):
            await self.assert_channel_received(channel, message)

    async def test_assert_channel_received_do_not_raise(self) -> None:
        """assert_channel_received does not raise an exception."""
        channel = await self.create_channel(self.channel_name)
        message = {"type": "work_request.assigned"}
        await channel["layer"].group_send(self.channel_name, message)

        await self.assert_channel_received(channel, message)

    async def test_assert_channel_nothing_received_do_not_raise(self) -> None:
        """assert_channel_nothing_received does not raise an exception."""
        channel = await self.create_channel(self.channel_name)
        await self.assert_channel_nothing_received(channel)

    async def test_assert_channel_nothing_receive_raise(self) -> None:
        """assert_channel_nothing_received raise exception: data is received."""
        channel = await self.create_channel(self.channel_name)
        message = {"type": "work_request.assigned"}

        await channel["layer"].group_send(self.channel_name, message)

        with self.assertRaisesRegex(
            self.failureException,
            "^Expected nothing. Received: '{'type': 'work_request.assigned'}'$",
        ):
            await self.assert_channel_nothing_received(channel)


class TestHelpersMixinTests(TestHelpersMixin, TestCase):
    """Tests for methods in TestHelpersMixin."""

    playground_memory_file_store = False

    def test_create_temp_config_directory(self) -> None:
        """create_temp_config_directory write the configuration."""
        config = {
            'General': {'default-server': 'debian'},
            'server:debian': {
                'url': 'https://debusine.debian.org',
                'token': 'token-for-debian',
            },
        }
        directory = self.create_temp_config_directory(config)

        actual_config = ConfigParser()
        actual_config.read(os.path.join(directory, 'config.ini'))

        expected_config = ConfigParser()
        expected_config.read_dict(config)

        self.assertEqual(actual_config, expected_config)

    def test_assert_dict_contains_subset_raises_exception(self) -> None:
        """Raise an exception (subset not in dictionary)."""
        expected_message = "{'b': 1} does not contain the subset {'a': 1}"
        with self.assertRaisesRegex(self.failureException, expected_message):
            self.assertDictContainsAll({'b': 1}, {'a': 1})

    def test_assert_dict_use_error_msg(self) -> None:
        """Raise exception using a specific error message."""
        expected_message = 'Missing values'

        with self.assertRaisesRegex(self.failureException, expected_message):
            self.assertDictContainsAll({}, {'a': 1}, expected_message)

    def test_assert_dict_contains_subset_does_not_raise_exception(self) -> None:
        """Do not raise any exception (subset in dictionary)."""
        self.assertDictContainsAll({'a': 1, 'b': 2}, {'a': 1})

    def test_assert_dict_contains_subset_arg1_not_a_dictionary(self) -> None:
        """Raise exception because of wrong type argument 1."""
        expected_message = (
            "'a' is not an instance of <class 'dict'> : "
            "First argument is not a dictionary"
        )
        with self.assertRaisesRegex(self.failureException, expected_message):
            self.assertDictContainsAll('a', {})  # type: ignore[arg-type]

    def test_assert_dict_contains_subset_arg2_not_a_dictionary(self) -> None:
        """Raise exception because of wrong type argument 2."""
        expected_message = (
            "'b' is not an instance of <class 'dict'> : "
            "Second argument is not a dictionary"
        )
        with self.assertRaisesRegex(self.failureException, expected_message):
            self.assertDictContainsAll({}, 'b')  # type: ignore[arg-type]

    def test_assert_raises_system_exit_no_system_exit(self) -> None:
        """Raise self.failureException because missing SystemExit."""
        expected_message = (
            r'SystemExit not raised : Did not raise '
            r'SystemExit with exit_code=\^3\$'
        )
        with self.assertRaisesRegex(self.failureException, expected_message):
            with self.assertRaisesSystemExit(3):
                pass

    def test_assert_raises_system_exit_unexpected_exit_code(self) -> None:
        """Raise self.failureException because wrong exit_code in SystemExit."""
        expected_message = (
            r'\^3\$" does not match "7" : Did not raise '
            r'SystemExit with exit_code=\^3\$'
        )
        with self.assertRaisesRegex(self.failureException, expected_message):
            with self.assertRaisesSystemExit(3):
                raise SystemExit(7)

    def test_assert_raises_system_exit_success(self) -> None:
        """Do not raise self.failureException: expected SystemExit is raised."""
        with self.assertRaisesSystemExit(3):
            raise SystemExit(3)

    @responses.activate
    def test_assert_token_key_included_in_all_requests(self) -> None:
        """Do not raise any exception (all requests had the token key)."""
        responses.add(
            responses.GET,
            'https://example.net/something',
        )
        responses.add(
            responses.GET,
            'https://example.net/something',
        )

        token_key = 'some-key'

        requests.get(
            'https://example.net/something',
            headers={'Token': token_key},
        )

        self.assert_token_key_included_in_all_requests(token_key)

    @responses.activate
    def test_assert_token_key_included_in_all_requests_raise_missing(
        self,
    ) -> None:
        """Raise exception because token not included in the request."""
        responses.add(
            responses.GET,
            'https://example.net/something',
        )

        requests.get('https://example.net/something')

        expected_message = (
            "Token missing in the headers for the request "
            "'https://example.net/something'"
        )

        with self.assertRaisesRegex(self.failureException, expected_message):
            self.assert_token_key_included_in_all_requests('some-token')

    @responses.activate
    def test_assert_token_key_included_in_all_requests_raise_mismatch(
        self,
    ) -> None:
        """Raise exception because token mismatch included in the request."""
        responses.add(
            responses.GET,
            'https://example.net/something',
        )

        token = 'token-for-server'

        requests.get(
            'https://example.net/something',
            headers={'Token': 'some-invalid-token'},
        )

        expected_message = (
            "Unexpected token. In the request: "
            "'https://example.net/something' "
            "Actual: 'some-invalid-token' Expected: 'token-for-server'"
        )

        with self.assertRaisesRegex(self.failureException, expected_message):
            self.assert_token_key_included_in_all_requests(token)

    def test_create_artifact_relation_default_type(self) -> None:
        """create_artifact_relation() create artifact with type=RELATED_TO."""
        artifact, _ = self.create_artifact()
        target, _ = self.create_artifact()
        created_artifact_relation = self.create_artifact_relation(
            artifact, target
        )
        created_artifact_relation.refresh_from_db

        self.assertEqual(created_artifact_relation.artifact, artifact)
        self.assertEqual(created_artifact_relation.target, target)
        self.assertEqual(
            created_artifact_relation.type,
            ArtifactRelation.Relations.RELATES_TO,
        )

    def test_create_artifact_relation_specific_type(self) -> None:
        """create_artifact_relation() create artifact with given type."""
        artifact, _ = self.create_artifact()
        target, _ = self.create_artifact()
        relation_type = ArtifactRelation.Relations.BUILT_USING

        created_artifact_relation = self.create_artifact_relation(
            artifact, target, relation_type
        )
        created_artifact_relation.refresh_from_db()

        self.assertEqual(created_artifact_relation.artifact, artifact)
        self.assertEqual(created_artifact_relation.target, target)
        self.assertEqual(created_artifact_relation.type, relation_type)

    def test_create_enabled_token(self) -> None:
        """create_token_enabled() returns a token that is enabled."""
        token = self.playground.create_token_enabled()
        self.assertTrue(token.enabled)

    def test_create_file_in_backend(self) -> None:
        """create_file_in_backend() returns a File and write contents to it."""
        file_backend = FileStore.default().get_backend_object()
        contents = b"some-test-data"

        fileobj = self.create_file_in_backend(
            file_backend,
            contents,
        )

        self.assertIsInstance(fileobj.id, int)
        local_path = file_backend.get_local_path(fileobj)
        assert local_path is not None
        self.assertEqual(local_path.read_bytes(), contents)

    def test_create_artifact_default_expire_date(self) -> None:
        """create_artifact() set expire_at to None by default."""
        artifact, _ = self.create_artifact()
        artifact.refresh_from_db()
        self.assertIsNone(artifact.expire_at)

    def test_create_artifact_expire_date(self) -> None:
        """create_artifact() set expire_at use correct expire_at.."""
        now = timezone.now()
        artifact, _ = self.create_artifact(expiration_delay=1)
        artifact.created_at = now - timedelta(days=1)
        artifact.save()
        artifact.refresh_from_db()
        self.assertEqual(artifact.expire_at, now)

    def test_create_artifact_create_files(self) -> None:
        """create_artifact() returns an Artifact and create files."""
        paths = ["src/a", "b"]
        files_size = 12

        artifact, files_contents = self.create_artifact(
            paths, files_size, create_files=True
        )

        self.assertEqual(artifact.fileinartifact_set.all().count(), len(paths))

        file_backend = (
            artifact.workspace.default_file_store.get_backend_object()
        )

        files_in_artifact = artifact.fileinartifact_set.all().order_by("path")

        for file_in_artifact in files_in_artifact:
            local_path = file_backend.get_local_path(file_in_artifact.file)
            assert local_path is not None
            self.assertEqual(
                local_path.read_bytes(), files_contents[file_in_artifact.path]
            )

    def test_create_artifact_do_not_create_files(self) -> None:
        """create_artifact() returns an Artifact and does not create files."""
        artifact, _ = self.create_artifact(["README"], create_files=False)

        self.assertEqual(artifact.fileinartifact_set.all().count(), 0)

    def test_create_artifact_raise_value_error(self) -> None:
        """create_artifact() raise ValueError: incompatible options."""
        with self.assertRaisesRegex(
            ValueError,
            "^skip_add_files_in_store must be False if create_files is False$",
        ):
            self.create_artifact(
                create_files=False, skip_add_files_in_store=True
            )

    def test_assertLogsContains_log_found(self) -> None:
        """assertLogsContains() does not raise self.failureException."""
        with self.assertLogsContains('required-log') as logs:
            logging.warning('required-log')

        self.assertEqual(logs.output, ["WARNING:root:required-log"])

    def test_assertLogsContains_log_expected_count_wrong(self) -> None:
        """assertLogsContains() raise self.failureException (wrong count)."""
        expected_message = (
            '^Expected: "required-log"\n'
            'Actual: "WARNING:root:required-log"\n'
            'Expected msg found 1 times, expected 2 times$'
        )

        with self.assertRaisesRegex(self.failureException, expected_message):
            with self.assertLogsContains('required-log', expected_count=2):
                logging.warning('required-log')

    def test_assertLogsContains_log_expected_not_found(self) -> None:
        """assertLogsContains() raise self.failureException (wrong count)."""
        expected_message = (
            '^Expected: "required-log"\n'
            'Actual: "WARNING:root:some-log"\n'
            'Expected msg found 0 times, expected 1 times$'
        )

        with self.assertRaisesRegex(self.failureException, expected_message):
            with self.assertLogsContains('required-log'):
                logging.warning('some-log')

    def test_assertLogsContains_log_expected_not_found_wrong_level(
        self,
    ) -> None:
        """assertLogsContains() raise self.failureException (wrong level)."""
        expected_message = (
            'no logs of level WARNING or higher triggered on root'
        )

        with self.assertRaisesRegex(self.failureException, expected_message):
            with self.assertLogsContains('required-log', level=logging.WARNING):
                logging.debug('some-log')

    def test_assertLogsContains_log_not_found_in_raise_exception(self) -> None:
        """
        assertLogsContains() raise self.failureException.

        It handles raised exceptions in the context code.
        """
        expected_message = (
            '^Expected: "The wanted message"\n'
            'Actual: "WARNING:root:Unrelated message"\n'
            'Expected msg found 0 times, expected 1 times$'
        )

        with self.assertRaisesRegex(self.failureException, expected_message):
            with (
                self.assertRaisesRegex(SystemExit, '3'),
                self.assertLogsContains('The wanted message'),
            ):
                logging.warning('Unrelated message')
                raise SystemExit(3)

    def test_assertResponseProblem_valid(self) -> None:
        """assertResponseProblem() does not raise any exception."""
        title = "Invalid task name"
        response = ProblemResponse(title)

        self._process_json_response(response)

        self.assertResponseProblem(response, title)

    def test_assertResponseProblem_assertions(self) -> None:
        """Exercise all the checks done by assertResponseProblem()."""
        for response, title, detail_pattern, expected_regex in (
            (
                ProblemResponse({}, status_code=status.HTTP_200_OK),
                "title",
                "detail",
                "response status 200 != 400",
            ),
            (
                Response({}, status=status.HTTP_400_BAD_REQUEST),
                "title",
                None,
                r"application/problem\+json",
            ),
            (
                Response(
                    {},
                    status=status.HTTP_400_BAD_REQUEST,
                    content_type="something_invalid",
                ),
                "title",
                None,
                r"something_invalid",
            ),
            (
                ProblemResponse("actual-title"),
                "expected-title",
                None,
                'title "actual-title" != "expected-title"',
            ),
            (
                ProblemResponse("actual-title"),
                "actual-title",
                "expected-detail",
                '"detail" not found in ',
            ),
            (
                ProblemResponse("actual-title", "actual-detail"),
                "actual-title",
                "expected-detail",
                '"expected-detail" did not match "actual-detail"',
            ),
            (
                ProblemResponse("actual-title"),
                "actual-title",
                "expected-detail",
                '"detail" not found in response',
            ),
        ):
            with self.subTest(
                response=response, title=title, detail_pattern=detail_pattern
            ):
                with self.assertRaisesRegex(
                    self.failureException, expected_regex
                ):
                    response = response
                    self._process_json_response(response)

                    self.assertResponseProblem(
                        response, title, detail_pattern=detail_pattern
                    )

    def test_create_token_enabled(self) -> None:
        """Test create token enabled."""
        comment = "A test"
        token = self.playground.create_token_enabled(comment=comment)

        token.refresh_from_db()
        self.assertEqual(token.comment, comment)
        self.assertIsNone(token.user)
        self.assertIsInstance(token, Token)

    def test_create_token_enabled_disabled(self) -> None:
        """Test create token enabled raise ValueError."""
        with self.assertRaisesRegex(
            ValueError, '"enabled" cannot be set in create_token_enabled'
        ):
            self.playground.create_token_enabled(enabled=False)

    def test_create_token_with_user(self) -> None:
        """Test create token with a new user associated."""
        token = self.playground.create_token_enabled(with_user=True)
        token.refresh_from_db()
        self.assertIsInstance(token.user, User)

    def test_create_token_existing_user(self) -> None:
        """Test create_token_enabled with_user use the existing user."""
        user = self.playground.get_default_user()
        user_count = get_user_model().objects.count()

        token = self.playground.create_token_enabled(with_user=True)
        token.refresh_from_db()
        self.assertEqual(token.user, user)

        self.assertEqual(get_user_model().objects.count(), user_count)

    @staticmethod
    def _process_json_response(response: HttpResponse) -> None:
        """
        Simulate part of what Django does when returning responses.

        It adds a new method (json()) returning json.loads(response.content).
        If response.content_type exist assign it to
        response.headers["Content-Type"].
        """
        response.json = lambda: json.loads(  # type: ignore[attr-defined]
            response.content
        )

        if content_type := getattr(response, "content_type", False):
            response.headers["Content-Type"] = content_type


class PermissionOverrideTests(TestCase):
    """Test permission overrides."""

    user: ClassVar[User]
    scope: ClassVar[Scope]
    scope1: ClassVar[Scope]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common test data."""
        super().setUpTestData()
        cls.scope = Scope.objects.get(name=settings.DEBUSINE_DEFAULT_SCOPE)
        cls.scope1 = Scope.objects.create(name="scope1")
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpassword"
        )

    def assert_all_allowed(
        self, obj: PermissionOverride[Scope], user: User | AnonymousUser
    ) -> None:
        """Check that all scopes test as allowed."""
        # Test allowed on permission check
        for scope in (self.scope, self.scope1):
            self.assertTrue(obj.check(scope, user))

        # Test allowed on permission filter
        self.assertQuerySetEqual(
            obj.filter(Scope.objects.all(), user),
            [self.scope, self.scope1],
            ordered=False,
        )

    def assert_allowed(
        self,
        obj: PermissionOverride[Scope],
        user: User | AnonymousUser,
        scopes: list[Scope],
    ) -> None:
        """Check that all the given scopes test as allowed."""
        # Test allowed on permission check
        for scope in scopes:
            self.assertTrue(obj.check(scope, user))
        for scope in {self.scope, self.scope1} - set(scopes):
            self.assertFalse(obj.check(scope, user))

        # Test allowed on permission filter
        self.assertQuerySetEqual(
            obj.filter(Scope.objects.all(), user),
            scopes,
            ordered=False,
        )

    def test_allow_all(self) -> None:
        """Test AllowAll.check."""
        obj = AllowAll[Scope]()
        for user in (AnonymousUser(), self.user):
            with self.subTest(user=user):
                self.assert_all_allowed(obj, user)

    def test_deny_all_check(self) -> None:
        """Test DenyAll.check."""
        obj = DenyAll[Scope]()
        for user in (AnonymousUser(), self.user):
            with self.subTest(user=user):
                self.assert_allowed(obj, user, [])

    def test_list_noop(self) -> None:
        """Test ListFilter predicate with no arguments: allows all."""
        obj = ListFilter[Scope]()
        for user in (AnonymousUser(), self.user):
            with self.subTest(user=user):
                self.assert_all_allowed(obj, user)

    def test_list_include(self) -> None:
        """Test ListFilter with include."""
        obj = ListFilter[Scope](include=[self.scope])
        for user in (AnonymousUser(), self.user):
            with self.subTest(user=user):
                self.assert_allowed(obj, user, [self.scope])

    def test_list_exclude(self) -> None:
        """Test ListFilter with exclude."""
        obj = ListFilter[Scope](exclude=[self.scope])
        for user in (AnonymousUser(), self.user):
            with self.subTest(user=user):
                self.assert_allowed(obj, user, [self.scope1])

    def test_override_permission(self) -> None:
        """Test override_permission as context manager."""
        with override_permission(Scope, "can_display", AllowAll):
            self.assertTrue(self.scope.can_display(self.user))
            self.assertQuerySetEqual(
                Scope.objects.can_display(self.user),
                [self.scope, self.scope1],
                ordered=False,
            )
            self.assertQuerySetEqual(
                Scope.objects.all().can_display(self.user),
                [self.scope, self.scope1],
                ordered=False,
            )
        with override_permission(Scope, "can_display", DenyAll):
            self.assertFalse(self.scope.can_display(self.user))
            self.assertQuerySetEqual(Scope.objects.can_display(self.user), [])
            self.assertQuerySetEqual(
                Scope.objects.all().can_display(self.user), []
            )

    @override_permission(Scope, "can_display", DenyAll)
    def test_override_permission_decorator(self) -> None:
        """Test override_permission as decorator."""
        self.assertFalse(self.scope.can_display(self.user))
