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

"""Views for the server application: collections."""

import logging
import re
from typing import Any, override

from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404
from rest_framework import mixins, status
from rest_framework.decorators import action
from rest_framework.parsers import JSONParser
from rest_framework.request import Request
from rest_framework.response import Response

from debusine.artifacts.models import (
    BareDataCategory,
    CollectionCategory,
    DebusineTaskConfiguration,
    SINGLETON_COLLECTION_CATEGORIES,
)
from debusine.db.context import context
from debusine.db.models import Collection, CollectionItem, CollectionTokenGrant
from debusine.db.models.collections import (
    CollectionItemQuerySet,
    CollectionQuerySet,
    CollectionTokenGrantQuerySet,
)
from debusine.db.models.permissions import anonymous_access
from debusine.server.collections import (
    DebianSuiteManager,
    DebusineTaskConfigurationManager,
    ItemRemovalError,
)
from debusine.server.exceptions import DebusineAPIException
from debusine.server.serializers import (
    BaseSerializerBase,
    CollectionItemSerializer,
    CollectionSerializer,
    CollectionTokenGrantSerializer,
    TaskConfigurationCollectionContents,
    TaskConfigurationCollectionContentsSerializer,
    TaskConfigurationCollectionUpdateResultsSerializer,
)
from debusine.server.views.base import BaseAPIView, GenericViewSetBase
from debusine.server.views.rest import IsUserTokenAuthenticated

logger = logging.getLogger(__name__)


class TaskConfigurationCollectionView(BaseAPIView):
    """View used to fetch and update a task configuration collection."""

    permission_classes = [IsUserTokenAuthenticated]
    parser_classes = [JSONParser]

    def _get_collection(self, workspace: str, name: str) -> Collection:
        """Lookup the task configuration collection."""
        self.set_current_workspace(workspace)
        try:
            collection = context.require_workspace().get_collection(
                pc=context.pc,
                category=CollectionCategory.TASK_CONFIGURATION,
                name=name,
            )
        except Collection.DoesNotExist:
            raise DebusineAPIException(
                title="Collection not found",
                detail=(
                    f"Task configuration collection {name!r} does not exist"
                    f" in workspace {workspace!r}"
                ),
                status_code=status.HTTP_404_NOT_FOUND,
            )

        self.enforce(collection.can_display)
        return collection

    def get(
        self,
        request: Request,  # noqa: ARG002, U100
        workspace: str,
        name: str,
    ) -> Response:
        """Retrieve the contents of the collection."""
        collection = self._get_collection(workspace, name)

        items = [
            DebusineTaskConfiguration.model_validate(child.data)
            for child in collection.child_items.active().filter(
                child_type=CollectionItem.Types.BARE
            )
        ]

        contents = TaskConfigurationCollectionContents(
            collection=collection, items=items
        )
        return Response(
            TaskConfigurationCollectionContentsSerializer(contents).data,
            status=status.HTTP_200_OK,
        )

    @staticmethod
    def _diff_items(
        collection: Collection, items: list[DebusineTaskConfiguration]
    ) -> tuple[list[CollectionItem], list[DebusineTaskConfiguration], int]:
        """
        Compare the collection contents against a new list of items.

        :returns: a tuple (removed, added, unchanged), where:
         * removed lists the CollectionItem entries to remove
         * added lists the DebusineTaskConfiguration items to add
         * unchanged is the count of unchanged entries
        """
        # Updates are not supported: something as small as changing a typo in
        # a comment deletes the old item and re-adds it: that way we do not
        # rewrite history

        # Load the existing items from the database
        old: dict[str, CollectionItem] = {}
        for child in collection.child_items.active().filter(
            child_type=CollectionItem.Types.BARE
        ):
            item = DebusineTaskConfiguration.model_validate(child.data)
            old[item.model_dump_json(exclude_unset=True)] = child

        # Index new items for comparison
        new: dict[str, DebusineTaskConfiguration] = {
            item.model_dump_json(exclude_unset=True): item for item in items
        }

        return (
            # Removed
            [old[k] for k in old.keys() - new.keys()],
            # Added
            [new[k] for k in new.keys() - old.keys()],
            # Unchanged
            len(old.keys() & new.keys()),
        )

    def _validate_git_commit(self, value: Any, name: str) -> str:
        if not isinstance(value, str):
            raise DebusineAPIException(
                title="Invalid git commit",
                detail=f"{name} is not a string",
                status_code=status.HTTP_400_BAD_REQUEST,
            )
        if not re.match(r"^[0-9a-f]{40,64}$", value):
            raise DebusineAPIException(
                title="Invalid git commit",
                detail=f"{name} is malformed",
                status_code=status.HTTP_400_BAD_REQUEST,
            )
        return value

    def post(self, request: Request, workspace: str, name: str) -> Response:
        """Update the contents of the collection."""
        collection = self._get_collection(workspace, name)
        self.enforce(collection.workspace.can_edit_task_configuration)
        dry_run = request.data.pop("dry_run", False)
        serializer = TaskConfigurationCollectionContentsSerializer(
            data=request.data
        )
        serializer.is_valid(raise_exception=True)
        submitted_collection = serializer.validated_data["collection"]

        if submitted_collection["id"] != collection.pk:
            raise DebusineAPIException(
                title="Posted collection mismatch",
                detail=(
                    f"Data posted for collection {submitted_collection['id']}"
                    f" to update collection {collection.pk}"
                ),
                status_code=status.HTTP_400_BAD_REQUEST,
            )

        if submitted_collection["name"] != collection.name:
            raise DebusineAPIException(
                title="Posted collection mismatch",
                detail=(
                    "Data posted for collection"
                    f" {submitted_collection['name']!r}"
                    f" to update collection {collection.name!r}"
                ),
                status_code=status.HTTP_400_BAD_REQUEST,
            )

        # Update git commit in collection data
        if (
            git_commit := submitted_collection["data"].get("git_commit")
        ) is not None:
            git_commit = self._validate_git_commit(git_commit, "git_commit")
            collection.data["git_commit"] = git_commit
        else:
            collection.data.pop("git_commit", None)
        collection.save()

        # We do not update existing items, as that would rewrite collection
        # history. If an item has been changed, we mark the old version as
        # deleted and create a new version
        removed, added, unchanged = self._diff_items(
            collection, serializer.validated_data["items"]
        )
        removed_names = {item.name for item in removed}

        # Update collection
        manager = DebusineTaskConfigurationManager(collection=collection)
        user = context.user
        assert user.is_authenticated
        if not dry_run:
            for removed_item in removed:
                manager.remove_item(removed_item, user=user)

        # Count updates as the number of added names that are also in removed
        updated: int = 0
        for added_item in added:
            if not dry_run:
                name = manager.add_bare_data(
                    BareDataCategory.TASK_CONFIGURATION,
                    user=user,
                    data=added_item,
                ).name
            else:
                name = added_item.name()
            if name in removed_names:
                updated += 1

        return Response(
            TaskConfigurationCollectionUpdateResultsSerializer(
                {
                    "added": len(added) - updated,
                    "removed": len(removed) - updated,
                    "updated": updated,
                    "unchanged": unchanged,
                }
            ).data,
            status=status.HTTP_200_OK,
        )


class CollectionViewSet(
    BaseAPIView,
    mixins.CreateModelMixin,
    mixins.RetrieveModelMixin,
    mixins.ListModelMixin,
    mixins.UpdateModelMixin,
    mixins.DestroyModelMixin,
    GenericViewSetBase[Collection],
):
    """Provide access to collections."""

    permission_classes = [IsUserTokenAuthenticated]
    parser_classes = [JSONParser]
    serializer_class = CollectionSerializer

    @override
    def initial(self, request: Request, *args: Any, **kwargs: Any) -> None:
        super().initial(request, *args, **kwargs)
        self.set_current_workspace(kwargs["workspace"])

    @override
    def get_queryset(self) -> CollectionQuerySet[Any]:
        """Filter accessible collections."""
        self.enforce(anonymous_access)  # filtered to can_display
        queryset = (
            Collection.objects.can_display(context.pc)
            .filter(workspace=context.require_workspace())
            .order_by("category", "name")
        )
        if self.action == "list":
            queryset = queryset.exclude(
                category=CollectionCategory.WORKFLOW_INTERNAL
            )
        return queryset

    @override
    def get_serializer_context(self) -> dict[str, Any]:
        context = dict(super().get_serializer_context())
        context['with_stats'] = self.request.GET.get("stats") == "1"
        return context

    @action(
        detail=False,
        url_path=r"(?P<category>[^/]+)/(?P<name>[^/]+)",
        url_name="lookup",
    )
    def lookup(
        self,
        request: Request,  # noqa: ARG002, U100
        workspace: str,  # noqa: ARG002, U100
        category: str,
        name: str,
    ) -> Response:
        """Look up a collection by name."""
        collection = get_object_or_404(
            self.get_queryset(), name=name, category=category
        )
        serializer = self.get_serializer(collection)
        return Response(serializer.data)

    def set_name(self, collection: Collection, name: str) -> bool:
        """
        Rename the collection.

        :return: True if the name has been changed
        """
        if name == collection.name:
            return False

        if (
            collection.category in SINGLETON_COLLECTION_CATEGORIES
            and name != "_"
        ):
            raise DebusineAPIException(
                title="Invalid name for singleton collection",
                detail=(
                    f"'{collection.category}' singleton collections"
                    " must be named '_'"
                ),
                status_code=status.HTTP_400_BAD_REQUEST,
            )

        if Collection.objects.filter(
            workspace=collection.workspace,
            category=collection.category,
            name=name,
        ).exists():
            raise DebusineAPIException(
                title="New name conflicts with an existing collection",
                detail=(
                    "A collection with category"
                    f" '{collection.category}' called {name!r}"
                    " already exists"
                ),
                status_code=status.HTTP_400_BAD_REQUEST,
            )

        collection.name = name
        return True

    def set_full_history_retention_period(
        self, collection: Collection, value: int | None
    ) -> bool:
        """
        Set the full history retention period.

        :return: True if the value has been changed
        """
        if value == collection.full_history_retention_period:
            return False
        collection.full_history_retention_period = value
        return True

    def set_metadata_only_retention_period(
        self, collection: Collection, value: int | None
    ) -> bool:
        """
        Set the full history retention period.

        :return: True if the value has been changed
        """
        if value == collection.metadata_only_retention_period:
            return False
        collection.metadata_only_retention_period = value
        return True

    def set_data(self, collection: Collection, value: dict[str, Any]) -> bool:
        """
        Set the collection data.

        :return: True if the value has been changed
        """
        if value == collection.data:
            return False
        # TODO: The key-value data should be validated against the
        # collection's pydantic data model, once we have it.
        # See https://salsa.debian.org/freexian-team/debusine/-/issues/923
        collection.data = value
        return True

    @override
    def perform_create(self, serializer: BaseSerializerBase[Any]) -> None:
        self.enforce(context.require_workspace().can_create_collections)
        try:
            serializer.save(workspace=context.require_workspace())
        except IntegrityError as e:
            detail = None
            message = str(e)
            if "db_collection_unique_name_category_workspace" in message:
                detail = "A collection with the same name already exists"
            raise DebusineAPIException(
                title="Cannot create collection", detail=detail
            )

    @override
    def perform_update(self, serializer: BaseSerializerBase[Any]) -> None:
        assert isinstance(serializer, CollectionSerializer)
        collection = serializer.instance
        assert isinstance(collection, Collection)
        self.enforce(collection.can_configure)

        changed: bool = False
        if "category" in serializer.validated_data:
            if serializer.validated_data["category"] != collection.category:
                raise DebusineAPIException(
                    title="The category of a collection cannot be changed",
                    status_code=status.HTTP_400_BAD_REQUEST,
                )
        if "name" in serializer.validated_data:
            changed = (
                self.set_name(collection, serializer.validated_data["name"])
                or changed
            )
        if "full_history_retention_period" in serializer.validated_data:
            changed = (
                self.set_full_history_retention_period(
                    collection,
                    serializer.validated_data["full_history_retention_period"],
                )
                or changed
            )
        if "metadata_only_retention_period" in serializer.validated_data:
            changed = (
                self.set_metadata_only_retention_period(
                    collection,
                    serializer.validated_data["metadata_only_retention_period"],
                )
                or changed
            )
        if "data" in serializer.validated_data:
            changed = (
                self.set_data(collection, serializer.validated_data["data"])
                or changed
            )

        if changed:
            collection.save()

    @override
    def perform_destroy(self, instance: Collection) -> None:
        self.enforce(instance.can_delete)
        instance.delete_collection()


class CollectionItemViewSet(
    BaseAPIView,
    mixins.RetrieveModelMixin,
    mixins.ListModelMixin,
    mixins.DestroyModelMixin,
    GenericViewSetBase[CollectionItem],
):
    """Provide access to collection items."""

    permission_classes = [IsUserTokenAuthenticated]
    parser_classes = [JSONParser]
    serializer_class = CollectionItemSerializer
    lookup_field = "name"
    lookup_url_kwarg = "name"
    lookup_value_regex = "[^/]+"

    collection: Collection

    @override
    def initial(self, request: Request, *args: Any, **kwargs: Any) -> None:
        """Return the initial request object."""
        super().initial(request, *args, **kwargs)
        self.set_current_workspace(kwargs["workspace"])
        workspace = context.require_workspace()
        self.enforce(workspace.can_display)
        try:
            self.collection = workspace.get_collection(
                pc=context.pc, category=kwargs["ccat"], name=kwargs["cname"]
            )
        except Collection.DoesNotExist:
            raise DebusineAPIException(
                title="Collection not found",
                detail=(
                    f"Collection '{kwargs['cname']}@{kwargs['ccat']}' does not"
                    f" exist in workspace {workspace}"
                ),
                status_code=status.HTTP_404_NOT_FOUND,
            )
        self.enforce(self.collection.can_display)

    @override
    def get_queryset(self) -> CollectionItemQuerySet:
        """Filter active items."""
        # Names of active items in a collection are unique, enforced by the
        # db_collectionitem_unique_active_name constraint.
        queryset = self.collection.child_items.active().order_by("name")
        if self.request.method == "GET":
            for field in ("name__startswith", "category"):
                if (param := self.request.query_params.get(field)) is not None:
                    queryset = queryset.filter(**{field: param})
        return queryset

    @override
    def perform_destroy(self, instance: CollectionItem) -> None:
        self.enforce(instance.can_delete)
        user = context.user
        assert user.is_authenticated
        try:
            self.collection.manager.remove_item(
                instance, user=user, remove_associated=True
            )
        except ItemRemovalError as e:
            raise DebusineAPIException(
                title="Cannot remove collection item", detail=str(e)
            )
        if self.collection.category == CollectionCategory.SUITE:
            assert isinstance(self.collection.manager, DebianSuiteManager)
            self.collection.manager.update_indexes(user=user)


class CollectionTokenGrantViewSet(
    BaseAPIView,
    mixins.CreateModelMixin,
    mixins.RetrieveModelMixin,
    mixins.ListModelMixin,
    mixins.DestroyModelMixin,
    GenericViewSetBase[CollectionTokenGrant],
):
    """Provide access to tokens granted roles on collections."""

    permission_classes = [IsUserTokenAuthenticated]
    parser_classes = [JSONParser]
    serializer_class = CollectionTokenGrantSerializer

    collection: Collection

    @override
    def initial(self, request: Request, *args: Any, **kwargs: Any) -> None:
        """Return the initial request object."""
        super().initial(request, *args, **kwargs)
        self.set_current_workspace(kwargs["workspace"])
        workspace = context.require_workspace()
        self.enforce(workspace.can_display)
        try:
            self.collection = workspace.get_collection(
                pc=context.pc, category=kwargs["ccat"], name=kwargs["cname"]
            )
        except Collection.DoesNotExist:
            raise DebusineAPIException(
                title="Collection not found",
                detail=(
                    f"Collection '{kwargs['cname']}@{kwargs['ccat']}' does not"
                    f" exist in workspace {workspace}"
                ),
                status_code=status.HTTP_404_NOT_FOUND,
            )
        self.enforce(self.collection.can_display)

    @classmethod
    def get_lookup_regex(cls, lookup_prefix: str = "") -> str:
        """Get the portion of the URL regex used to match a single grant."""
        return (
            f"(?P<{lookup_prefix}token>[^/]+)/(?P<{lookup_prefix}role>[^/.]+)"
        )

    @override
    def get_queryset(self) -> CollectionTokenGrantQuerySet[Any]:
        """Filter accessible and relevant tokens."""
        return self.collection.token_grants.order_by(
            Coalesce("token__user__username", "token__external_username"),
            "token__hash",
            "role",
            "id",
        )

    @override
    def get_object(self) -> CollectionTokenGrant:
        queryset = self.filter_queryset(self.get_queryset())
        obj = get_object_or_404(
            queryset, token__hash=self.kwargs["token"], role=self.kwargs["role"]
        )
        assert isinstance(obj, CollectionTokenGrant)
        self.check_object_permissions(self.request, obj)
        return obj

    @override
    def perform_create(
        self, serializer: BaseSerializerBase[CollectionTokenGrant]
    ) -> None:
        self.enforce(self.collection.can_configure)
        try:
            serializer.save(collection=self.collection, created_by=context.user)
        except ValidationError as e:
            raise DebusineAPIException(
                title="Cannot grant collection token",
                validation_errors=e.message_dict,
            )

    @override
    def perform_destroy(self, instance: CollectionTokenGrant) -> None:
        self.enforce(self.collection.can_configure)
        super().perform_destroy(instance)
