# Copyright © 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 lookup views."""

from abc import ABC, abstractmethod
from typing import Any, ClassVar, override

from django.urls import reverse
from rest_framework import status

from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.client.models import LookupChildType, LookupResultType
from debusine.db.models import (
    CollectionItem,
    Token,
    User,
    WorkRequest,
    Workspace,
)
from debusine.server.views.base import BaseAPIView
from debusine.server.views.lookups import LookupMultipleView, LookupSingleView
from debusine.server.views.rest import IsUserTokenAuthenticated
from debusine.tasks.models import LookupMultiple
from debusine.test.django import TestCase, TestResponseType


class LookupTestCase[LookupType](TestCase, ABC):
    """Common tests for Single and Multiple Lookups."""

    user: ClassVar[User]
    token: ClassVar[Token]
    workspace: ClassVar[Workspace]
    view: ClassVar[type[BaseAPIView]]

    # We need at least one abstract method to avoid pytest running this ABC
    # directly.
    # trixie's coverage thinks this needs to be tested
    @property
    @abstractmethod
    def api_endpoint(self) -> str: ...  # pragma: no cover

    def __init_subclass__(cls, /, view: type[BaseAPIView]) -> None:
        """Configure LookupTestCase."""
        cls.view = view

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common data for tests."""
        super().setUpTestData()
        cls.workspace = cls.playground.create_workspace(
            name="public", public=True
        )
        cls.user = cls.playground.get_default_user()
        cls.token = cls.playground.create_user_token()

    def test_unauthenticated(self) -> None:
        """Authentication is required."""
        self.assertIn(IsUserTokenAuthenticated, self.view.permission_classes)

        response = self.client.post(
            self.api_endpoint,
            data={
                "lookup": "",
                "workspace": self.workspace.name,
                "expect_type": LookupChildType.ARTIFACT,
            },
            content_type="application/json",
        )

        self.assertEqual(
            response.json(),
            {
                "title": "Error",
                "detail": "Authentication credentials were not provided.",
            },
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_workspace_not_found(self) -> None:
        """The work request must exist."""
        response = self.client.post(
            self.api_endpoint,
            data={
                "lookup": "x",
                "workspace": "unknown",
                "expect_type": LookupChildType.ARTIFACT,
            },
            headers={"token": self.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            "Error",
            detail_pattern="Object with name=unknown does not exist.",
        )

    def test_workspace_not_visible(self) -> None:
        """The workspace must be visible to the user."""
        workspace = self.playground.create_workspace(name="private")

        response = self.client.post(
            self.api_endpoint,
            data={
                "lookup": "x",
                "workspace": workspace.name,
                "expect_type": LookupChildType.ARTIFACT,
            },
            headers={"token": self.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            f"{self.user} cannot display workspace {workspace}",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_workflow_root_not_found(self) -> None:
        """The workflow_root must exist, if specified."""
        response = self.client.post(
            self.api_endpoint,
            data={
                "lookup": "x",
                "workspace": self.workspace.name,
                "workflow_root": -1,
                "expect_type": LookupChildType.ARTIFACT,
            },
            headers={"token": self.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response, "Error", detail_pattern="Invalid pk"
        )

    def test_workflow_root_not_visible(self) -> None:
        """The workflow_root must be visible to the user, if specified."""
        private = self.playground.create_workspace(name="private")
        work_request = self.playground.create_worker_task(workspace=private)

        response = self.client.post(
            self.api_endpoint,
            data={
                "lookup": "x",
                "workspace": self.workspace.name,
                "workflow_root": work_request.id,
                "expect_type": LookupChildType.ARTIFACT,
            },
            headers={"token": self.token.key},
            content_type="application/json",
        )

        self.assertResponseProblem(
            response,
            f"{self.user} cannot display work request {work_request}",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def post_lookup(
        self,
        lookup: LookupType,
        expect_type: LookupChildType,
        default_category: CollectionCategory | None = None,
        workflow_root: WorkRequest | None = None,
    ) -> TestResponseType:
        """Make a lookup request."""
        data = {
            "lookup": lookup,
            "workspace": self.workspace.name,
            "expect_type": expect_type,
            "default_category": default_category,
        }
        if workflow_root:
            data["workflow_root"] = workflow_root.id
        return self.client.post(
            self.api_endpoint,
            data=data,
            headers={"token": self.token.key},
            content_type="application/json",
        )


class LookupSingleViewTests(
    LookupTestCase[int | str],
    view=LookupSingleView,
):
    """Tests for LookupSingleView."""

    @override
    @property
    def api_endpoint(self) -> str:
        return reverse("api:lookup-single")

    def test_success_string(self) -> None:
        """A string lookup succeeds and returns an artifact ID."""
        artifact = self.playground.create_artifact(workspace=self.workspace)[0]

        response = self.post_lookup(
            f"{artifact.id}@artifacts", LookupChildType.ARTIFACT
        )

        data = self.assertAPIResponseOk(response)
        self.assertEqual(
            data,
            {
                "result_type": LookupResultType.ARTIFACT,
                "collection_item": None,
                "artifact": artifact.id,
                "collection": None,
            },
        )

    def test_success_integer(self) -> None:
        """An integer lookup succeeds and returns an artifact ID."""
        artifact = self.playground.create_artifact(workspace=self.workspace)[0]

        response = self.post_lookup(artifact.id, LookupChildType.ARTIFACT)

        data = self.assertAPIResponseOk(response)
        self.assertEqual(
            data,
            {
                "result_type": LookupResultType.ARTIFACT,
                "collection_item": None,
                "artifact": artifact.id,
                "collection": None,
            },
        )

    def test_key_error(self) -> None:
        """The view returns an error if the lookup returns no items."""
        response = self.post_lookup(
            "nonsense@artifacts", LookupChildType.ARTIFACT
        )

        self.assertResponseProblem(
            response,
            "No matches",
            detail_pattern="'nonsense@artifacts' does not exist or is hidden",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_lookup_error(self) -> None:
        """The view returns an error if the lookup is invalid."""
        response = self.post_lookup(
            "internal@collections", LookupChildType.ARTIFACT
        )

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "internal@collections is only valid in the context of a "
                "workflow"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_wrong_workspace(self) -> None:
        """The item must be in a visible workspace."""
        workspace = self.playground.create_workspace(name="test")
        artifact = self.playground.create_artifact(workspace=workspace)[0]
        lookup = f"{artifact.id}@artifacts"

        response = self.post_lookup(lookup, LookupChildType.ARTIFACT)

        self.assertResponseProblem(
            response,
            "No matches",
            detail_pattern=f"{lookup!r} does not exist or is hidden",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_wrong_type(self) -> None:
        """The item must be of the correct type."""
        artifact = self.playground.create_artifact(workspace=self.workspace)[0]
        lookup = f"{artifact.id}@artifacts"

        response = self.post_lookup(lookup, LookupChildType.COLLECTION)

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=f"{lookup!r} is of type 'artifact'"
            " instead of expected 'collection'",
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_default_category(self) -> None:
        """The client can request a default category."""
        collection = self.playground.create_collection(
            "collection", CollectionCategory.TEST, workspace=self.workspace
        )

        response = self.post_lookup("collection", LookupChildType.COLLECTION)

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "'collection' does not specify a category and the context "
                "does not supply a default"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

        response = self.post_lookup(
            "collection",
            LookupChildType.COLLECTION,
            default_category=CollectionCategory.TEST,
        )

        data = self.assertAPIResponseOk(response)
        self.assertEqual(
            data,
            {
                "result_type": LookupResultType.COLLECTION,
                "collection_item": None,
                "artifact": None,
                "collection": collection.id,
            },
        )

    def test_workflow_root(self) -> None:
        """Lookup in the internal collection of a workflow."""
        artifact, _ = self.playground.create_artifact(workspace=self.workspace)
        workflow = self.playground.create_workflow(
            internal_collection_artifacts=[(artifact, {"name": "test"})],
            workspace=self.workspace,
        )
        assert workflow.internal_collection
        collection_item = workflow.internal_collection.manager.lookup(
            "name:test"
        )
        assert collection_item

        response = self.post_lookup(
            "internal@collections/name:test",
            LookupChildType.ARTIFACT,
            workflow_root=workflow,
        )

        data = self.assertAPIResponseOk(response)
        self.assertEqual(
            data,
            {
                "result_type": LookupResultType.ARTIFACT,
                "collection_item": collection_item.id,
                "artifact": artifact.id,
                "collection": None,
            },
        )


class LookupMultipleViewTests(
    LookupTestCase[dict[str, Any] | list[str | dict[str, Any]]],
    view=LookupMultipleView,
):
    """Tests for LookupMultipleView."""

    @override
    @property
    def api_endpoint(self) -> str:
        return reverse("api:lookup-multiple")

    def test_success(self) -> None:
        """The lookup succeeds and returns an artifact ID."""
        collection = self.playground.create_collection(
            "collection", CollectionCategory.TEST, workspace=self.workspace
        )
        artifacts = [
            self.playground.create_artifact(category=ArtifactCategory.TEST)[0]
            for _ in range(2)
        ]
        collection_items = [
            CollectionItem.objects.create(
                parent_collection=collection,
                name=f"artifact{i}",
                child_type=CollectionItem.Types.ARTIFACT,
                category=ArtifactCategory.TEST,
                artifact=artifact,
                data={},
                created_by_user=self.playground.get_default_user(),
            )
            for i, artifact in enumerate(artifacts)
        ]

        response = self.post_lookup(
            {"collection": f"collection@{CollectionCategory.TEST}"},
            LookupChildType.ARTIFACT,
        )

        data = self.assertAPIResponseOk(response)
        self.assertCountEqual(
            data,
            [
                {
                    "result_type": LookupResultType.ARTIFACT,
                    "collection_item": collection_item.id,
                    "artifact": artifact.id,
                    "collection": None,
                }
                for artifact, collection_item in zip(
                    artifacts, collection_items
                )
            ],
        )

    def test_normalized(self) -> None:
        """The view accepts fully-normalized lookups."""
        collection = self.playground.create_collection(
            "collection", CollectionCategory.TEST, workspace=self.workspace
        )
        artifacts = [
            self.playground.create_artifact(category=ArtifactCategory.TEST)[0]
            for _ in range(2)
        ]
        collection_items = [
            CollectionItem.objects.create(
                parent_collection=collection,
                name=f"artifact{i}",
                child_type=CollectionItem.Types.ARTIFACT,
                category=ArtifactCategory.TEST,
                artifact=artifact,
                data={},
                created_by_user=self.playground.get_default_user(),
            )
            for i, artifact in enumerate(artifacts)
        ]

        lookup = LookupMultiple.model_validate(
            {"collection": f"collection@{CollectionCategory.TEST}"}
        ).model_dump()
        self.assertEqual(
            lookup,
            (
                {
                    "category": None,
                    "child_type": "artifact",
                    "collection": f"collection@{CollectionCategory.TEST}",
                    "data_matchers": (),
                    "name_matcher": None,
                    "lookup_filters": (),
                    "error_on_empty": False,
                },
            ),
        )

        response = self.post_lookup(lookup, LookupChildType.ARTIFACT)

        data = self.assertAPIResponseOk(response)
        self.assertCountEqual(
            data,
            [
                {
                    "result_type": LookupResultType.ARTIFACT,
                    "collection_item": collection_item.id,
                    "artifact": artifact.id,
                    "collection": None,
                }
                for artifact, collection_item in zip(
                    artifacts, collection_items
                )
            ],
        )

    def test_deserialization_error(self) -> None:
        """The view returns an error if it cannot deserialize the lookup."""
        response = self.post_lookup(
            "nonsense",  # type: ignore[arg-type]
            LookupChildType.ARTIFACT,
        )

        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertEqual(
            response.headers["Content-Type"], "application/problem+json"
        )
        self.assertEqual(response.json()["title"], "Cannot deserialize lookup")
        self.assertRegex(
            response.json()["validation_errors"]["lookup"],
            r"^1 validation error for LookupMultiple",
        )

    def test_key_error(self) -> None:
        """The view returns an empty list if the lookup returns no items."""
        response = self.post_lookup(
            {"collection": "nonexistent@test"}, LookupChildType.ARTIFACT
        )

        self.assertResponseProblem(
            response,
            "One of the lookups returned no matches",
            detail_pattern="'nonexistent@test' does not exist or is hidden",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_lookup_error(self) -> None:
        """The view returns an error if the lookup is invalid."""
        response = self.post_lookup(
            {"collection": "internal@collections"},
            LookupChildType.ARTIFACT,
        )

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "internal@collections is only valid in the context of a "
                "workflow"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_wrong_workspace(self) -> None:
        """The item must be in a visible workspace."""
        workspace = self.playground.create_workspace(name="test")
        self.playground.create_collection(
            "collection", CollectionCategory.TEST, workspace=workspace
        )

        response = self.post_lookup(
            [{"collection": f"collection@{CollectionCategory.TEST}"}],
            LookupChildType.ARTIFACT,
        )

        self.assertResponseProblem(
            response,
            "One of the lookups returned no matches",
            detail_pattern=(
                f"'collection@{CollectionCategory.TEST}' does not exist or is "
                f"hidden"
            ),
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_wrong_type(self) -> None:
        """The lookup must be of the correct type."""
        collection = self.playground.create_collection(
            "collection", CollectionCategory.TEST, workspace=self.workspace
        )
        self.playground.create_bare_data_item(collection, "bare")
        lookup = {
            "collection": f"collection@{CollectionCategory.TEST}",
            "child_type": "bare",
        }

        response = self.post_lookup(lookup, LookupChildType.COLLECTION)

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                f"Result from {lookup!r} is of type 'bare' instead of expected"
                f" 'collection'"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_default_category(self) -> None:
        """The client can request a default category."""
        collection = self.playground.create_collection(
            "collection", CollectionCategory.TEST, workspace=self.workspace
        )
        artifact = self.playground.create_artifact(
            category=ArtifactCategory.TEST
        )[0]
        collection_item = CollectionItem.objects.create(
            parent_collection=collection,
            name="artifact",
            child_type=CollectionItem.Types.ARTIFACT,
            category=ArtifactCategory.TEST,
            artifact=artifact,
            data={},
            created_by_user=self.playground.get_default_user(),
        )

        response = self.post_lookup(
            {"collection": "collection"}, LookupChildType.ARTIFACT
        )

        self.assertResponseProblem(
            response,
            "Lookup error",
            detail_pattern=(
                "'collection' does not specify a category and the context "
                "does not supply a default"
            ),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

        response = self.post_lookup(
            {"collection": "collection"},
            LookupChildType.ARTIFACT,
            default_category=CollectionCategory.TEST,
        )

        data = self.assertAPIResponseOk(response)
        self.assertEqual(
            data,
            [
                {
                    "result_type": LookupResultType.ARTIFACT,
                    "collection_item": collection_item.id,
                    "artifact": artifact.id,
                    "collection": None,
                }
            ],
        )
