# 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 datetime import timedelta
from typing import (
    Any,
    ClassVar,
    Protocol,
    TYPE_CHECKING,
    cast,
    runtime_checkable,
)
from unittest.util import safe_repr

import django.test
import requests
from channels.layers import get_channel_layer
from django.db.models import Model
from django.http.response import HttpResponseBase
from django.utils import timezone

from debusine.artifacts.models import ArtifactCategory
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    File,
    FileUpload,
    Token,
    User,
    WorkRequest,
    Workspace,
)
from debusine.server.file_backend.interface import FileBackendInterface
from debusine.test.playground import Playground

# Relative import needed to break an import loop
from .base import TestCase as BaseTestCase  # noqa: ABS101

if TYPE_CHECKING:
    from django.test.client import _MonkeyPatchedWSGIResponse


@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: "_MonkeyPatchedWSGIResponse", error: str
    ) -> None:
        """Assert that response is Http400 and contents is in response."""
        self.assertEqual(response.status_code, 400)
        self.assertEqual(response.context["error"], error)

    def assertResponseProblem(
        self,
        response: HttpResponseBase,
        title: str,
        detail_pattern: str | None = None,
        status_code: int = requests.codes.bad_request,
    ) -> None:
        """
        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) -> User:
        """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) -> FileUpload:
        """
        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") -> File:
        """
        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,
        expired: bool = False,
        **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 expired: True to create it expired
          (created_at: 1 year, expiration_delay: 1 day)
        :param kwargs: use them when creating the WorkRequest model
        """
        extra_kwargs: dict[str, Any] = {
            "mark_running": mark_running,
            "assign_new_worker": assign_new_worker,
            "result": result,
        }

        if expired:
            extra_kwargs.update(
                created_at=timezone.now() - timedelta(days=365),
                expiration_delay=timedelta(days=1),
            )

        return cls.playground.create_work_request(**{**extra_kwargs, **kwargs})

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

    def assert_work_request_event_reactions(
        self,
        work_request: WorkRequest,
        *,
        on_creation: list[dict[str, Any]] | None = None,
        on_unblock: list[dict[str, Any]] | None = None,
        on_success: list[dict[str, Any]] | None = None,
        on_failure: list[dict[str, Any]] | None = None,
    ) -> None:
        """
        Assert that a work request has the given event reactions.

        The order of the items in each list of event reactions does not
        matter.  Leaving parameters unset or None is equivalent to passing
        the empty list, meaning that they are required to be empty.
        """
        self.assertCountEqual(
            work_request.event_reactions_json.get("on_creation", []),
            on_creation or [],
        )
        self.assertCountEqual(
            work_request.event_reactions_json.get("on_unblock", []),
            on_unblock or [],
        )
        self.assertCountEqual(
            work_request.event_reactions_json.get("on_success", []),
            on_success or [],
        )
        self.assertCountEqual(
            work_request.event_reactions_json.get("on_failure", []),
            on_failure or [],
        )


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]
    ) -> None:
        """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
    ) -> None:
        """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)
            )
