# 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 django.contrib.auth import get_user_model
from django.http.response import HttpResponse
from django.test import TestCase
from django.utils import timezone

import requests

import responses

from rest_framework import status
from rest_framework.response import Response

from debusine.db.models import (
    ArtifactRelation,
    FileStore,
    Token,
    User,
    WorkRequest,
    default_workspace,
)
from debusine.server.views import ProblemResponse
from debusine.test import TestHelpersMixin
from debusine.test.django import ChannelsHelpersMixin


# 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.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_work_request(self) -> None:
        """Test create_work_request return a saved work request."""
        work_request = self.create_work_request()
        work_request.refresh_from_db()
        self.assertIsInstance(work_request, WorkRequest)
        self.assertEqual(work_request.workspace, default_workspace())

    def test_create_work_request_use_created_by_user(self) -> None:
        """Test create_work_request use created_by Token."""
        user = get_user_model().objects.create_user(
            username="testuser", password="testpassword"
        )
        work_request = self.create_work_request(created_by=user)
        work_request.refresh_from_db()
        self.assertEqual(work_request.created_by, user)

    def test_create_token_enabled(self) -> None:
        """Test create token enabled."""
        comment = "A test"
        token = self.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.create_token_enabled(enabled=False)

    def test_create_token_with_user(self) -> None:
        """Test create token with a new user associated."""
        token = self.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.get_test_user()
        user_count = get_user_model().objects.count()

        token = self.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 = (  # type: ignore[attr-defined]
            lambda: json.loads(response.content)
        )

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