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

"""Common test-helper code involving django usage."""

import asyncio
import unittest
from typing import (
    Any,
    ClassVar,
    Protocol,
    TYPE_CHECKING,
    cast,
    runtime_checkable,
)
from unittest.util import safe_repr

from channels.layers import get_channel_layer

import django.test
from django.db.models import Model

import requests

# Relative import needed to break an import loop
from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    Collection,
    File,
    Token,
    User,
    WorkRequest,
    WorkflowTemplate,
    Workspace,
)
from debusine.server.file_backend.interface import FileBackendInterface
from debusine.test.playground import Playground

from .base import TestCase as BaseTestCase  # noqa: ABS101

if TYPE_CHECKING:
    from debusine.server.views import ProblemResponse


@runtime_checkable
class JSONResponseProtocol(Protocol):
    """A Django test client response with a monkey-patched json() method."""

    def json(self) -> Any:
        """Return the body of the response, parsed as JSON."""


class PlaygroundMixin:
    """Playground-specific test case setup."""

    #: set to False if you do not need a playground in your test case
    playground_needed: bool = True

    #: set to False if you need a playground with a LocalFileStore instead of
    #: an in-memory version
    playground_memory_file_store: bool = True

    @classmethod
    def create_playground(cls) -> Playground:
        """
        Create the playground object.

        This method can be overridden by subclasses to create a playground with
        a different configuration
        """
        return Playground(memory_file_store=cls.playground_memory_file_store)


class BaseDjangoTestCase(django.test.SimpleTestCase, BaseTestCase):
    """
    Django-specific Debusine test methods.

    This augments debusine.test.TestCase with django-specific assert statements
    and factory functions.

    This is the common base class for TestCase and TransactionTestCase, for
    tests that do depend on Django code.
    """

    def assertResponse400(self, response, contents: str):
        """Assert that response is Http400 and contents is in response."""
        self.assertContains(
            response,
            contents,
            status_code=400,
            html=True,
        )

    def assertResponseProblem(
        self,
        response: "ProblemResponse",
        title: str,
        detail_pattern: str | None = None,
        status_code: int = requests.codes.bad_request,
    ):
        """
        Assert that response is a valid application/problem+json.

        Assert that the content_type is application/problem+json and the
        title exist and matches title.

        :param response: response that it is asserting
        :param status_code: assert response.status_code == status_code
        :param title: exact match with response.data["title"]
        :param detail_pattern: if not None: assertRegex with
           response.data["detail"]. If None checks that response.data does not
           contain "detail".
        """
        self.assertEqual(
            response.status_code,
            status_code,
            f"response status {response.status_code} != {status_code}",
        )

        content_type = response.headers["Content-Type"]
        self.assertEqual(
            content_type,
            "application/problem+json",
            f'content_type "{content_type}" != ' f'"application/problem+json"',
        )

        assert isinstance(response, JSONResponseProtocol)
        data = response.json()

        self.assertIn("title", data, '"title" not found in response')

        response_title = data["title"]
        self.assertEqual(
            response_title, title, f'title "{response_title}" != "{title}"'
        )

        if detail_pattern is not None:
            self.assertIn("detail", data, '"detail" not found in response')

            response_detail = str(data["detail"])
            self.assertRegex(
                response_detail,
                detail_pattern,
                f'Detail regexp "{detail_pattern}" did not '
                f'match "{response_detail}"',
            )
        else:
            self.assertNotIn("detail", data, '"detail" is in the response')


class TransactionTestCase(
    PlaygroundMixin,
    BaseDjangoTestCase,
    django.test.TransactionTestCase,
):
    """Debusine-specific extensions to django's TransactionTestCase."""

    playground: Playground

    def setUp(self) -> None:
        """Create a playground for TransactionTestCase."""
        super().setUp()
        if self.playground_needed:
            # TransactionTestCase does not support setUpTestData, so we need a
            # playground instance per test method
            self.playground = self.enterContext(self.create_playground())


class TestCase(
    PlaygroundMixin,
    BaseDjangoTestCase,
    django.test.TestCase,
):
    """Debusine-specific extensions to django's TestCase."""

    playground: ClassVar[Playground]

    @classmethod
    def setUpTestData(cls) -> None:
        """Create a playground to setup test data."""
        super().setUpTestData()
        if cls.playground_needed:
            # For TransactionTestCase, setUpTestData is not called, and a
            # playground will have to be instantiated in setUp instead
            cls.playground = cls.enterClassContext(cls.create_playground())

    @classmethod
    def get_test_user(cls):
        """Return a test user."""
        return cls.playground.get_default_user()

    @classmethod
    def create_token_enabled(
        cls, with_user: bool = False, **kwargs: Any
    ) -> Token:
        """
        Return an enabled Token.

        :param with_user: if True it assigns a User to this token.
        """
        return cls.playground.create_token_enabled(with_user, **kwargs)

    @classmethod
    def add_user_permission(
        cls, user: User, model: type[Model], codename: str
    ) -> None:
        """Add a permission to a user."""
        cls.playground.add_user_permission(user, model, codename)

    @classmethod
    def create_file_in_backend(
        cls,
        backend: FileBackendInterface[Any],
        contents: bytes = b"test",
    ) -> File:
        """
        Create a temporary file and adds it in the backend.

        :param backend: file backend to add the file in
        :param contents: contents of the file
        """
        return cls.playground.create_file_in_backend(backend, contents)

    @classmethod
    def create_artifact_relation(
        cls,
        artifact: Artifact,
        target: Artifact,
        relation_type: ArtifactRelation.Relations = (
            ArtifactRelation.Relations.RELATES_TO
        ),
    ) -> ArtifactRelation:
        """Create an ArtifactRelation."""
        return cls.playground.create_artifact_relation(
            artifact, target, relation_type
        )

    @classmethod
    def create_artifact(
        cls,
        paths: list[str] | None = None,
        files_size: int = 100,
        *,
        category: ArtifactCategory = ArtifactCategory.TEST,
        workspace: Workspace | None = None,
        data: dict[str, Any] | None = None,
        expiration_delay: int | None = None,
        work_request: WorkRequest | None = None,
        created_by: User | None = None,
        create_files: bool = False,
        skip_add_files_in_store: bool = False,
    ) -> tuple[Artifact, dict[str, bytes]]:
        """
        Create an artifact and return tuple with the artifact and files data.

        :param paths: list of paths to create (will contain random data)
        :param files_size: size of the test data
        :param category: this artifact's category (see :ref:`artifacts`)
        :param data: key-value data for this artifact (see :ref:`artifacts`)
        :param expiration_delay: set expiration_delay field (in days)
        :param work_request: work request that created this artifact
        :param created_by: set Artifact.created_by to it
        :param create_files: create a file and add it into the LocalFileBackend
        :param skip_add_files_in_store: do not add the files in the store
          (only create the File object in the database)

        This method return a tuple:
        - artifact: Artifact
        - files_contents: Dict[str, bytes] (paths and test data)
        """
        return cls.playground.create_artifact(
            paths,
            files_size,
            category=category,
            workspace=workspace,
            data=data,
            expiration_delay=expiration_delay,
            work_request=work_request,
            created_by=created_by,
            create_files=create_files,
            skip_add_files_in_store=skip_add_files_in_store,
        )

    @classmethod
    def create_file_upload(cls):
        """
        Create a new FileUpload object.

        Create the workspace, artifact, file and file_in_artifact associated
        to the file_upload object.
        """
        return cls.playground.create_file_upload()

    @classmethod
    def create_file(cls, contents: bytes = b"test"):
        """
        Create a File model and return the saved fileobj.

        :param contents: used to compute hash digest and size
        """
        return cls.playground.create_file(contents)

    @classmethod
    def create_work_request(
        cls,
        mark_running: bool = False,
        assign_new_worker: bool = False,
        result: WorkRequest.Results | None = None,
        **kwargs: Any,
    ) -> WorkRequest:
        """
        Return a new instance of WorkRequest.

        :param mark_running: if True call mark_running() method
        :param assign_new_worker: if not None assign worker to the work request
        :param result: if not None call mark_completed(result)
        :param kwargs: use them when creating the WorkRequest model
        """
        return cls.playground.create_work_request(
            mark_running=mark_running,
            assign_new_worker=assign_new_worker,
            result=result,
            **kwargs,
        )

    @classmethod
    def create_workspace(cls, **kwargs) -> Workspace:
        """Create a Workspace and return it."""
        return cls.playground.create_workspace(**kwargs)

    @classmethod
    def create_collection(
        cls,
        name: str,
        category: CollectionCategory,
        *,
        workspace: Workspace | None = None,
        data: dict[str, Any] | None = None,
    ) -> Collection:
        """Create a collection."""
        return cls.playground.create_collection(
            name, category, workspace=workspace, data=data
        )

    @classmethod
    def create_workflow_template(
        cls,
        name: str,
        task_name: str,
        *,
        workspace: Workspace | None = None,
        task_data: dict[str, Any] | None = None,
        **kwargs: Any,
    ) -> WorkflowTemplate:
        """Create a workflow template."""
        return cls.playground.create_workflow_template(
            name, task_name, workspace=workspace, task_data=task_data
        )


class ChannelsHelpersMixin:
    """
    Channel-related methods to help writing unit tests.

    Provides methods to setup a channel and assert messages or lack of messages.
    """

    # TODO: coverage is confused by something here, possibly
    # https://github.com/python/cpython/issues/106749
    async def create_channel(
        self, group_name
    ) -> dict[str, Any]:  # pragma: no cover
        """
        Create a channel and add it to the group named ``group_name``.

        Return dict with layer and name.
        """
        channel_layer = get_channel_layer()
        channel_name = await channel_layer.new_channel()
        await channel_layer.group_add(group_name, channel_name)

        return {"layer": channel_layer, "name": channel_name}

    async def assert_channel_nothing_received(self, channel: dict[str, Any]):
        """Assert that nothing is received in channel."""
        try:
            received = await asyncio.wait_for(
                channel["layer"].receive(channel["name"]), timeout=0.1
            )
        except asyncio.exceptions.TimeoutError:
            pass
        else:
            cast(unittest.TestCase, self).fail(
                "Expected nothing. Received: '%s'" % safe_repr(received)
            )

    async def assert_channel_received(self, channel: dict[str, Any], data):
        """Assert that data is received in channel_layer, channel_name."""
        try:
            received = await asyncio.wait_for(
                channel["layer"].receive(channel["name"]), timeout=0.1
            )
            cast(unittest.TestCase, self).assertEqual(received, data)
        except asyncio.exceptions.TimeoutError:
            cast(unittest.TestCase, self).fail(
                "Expected '%s' received nothing" % safe_repr(data)
            )
