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

"""Task to copy items into target collections."""

import shutil
import tempfile
from pathlib import Path
from typing import Any, override

from django.conf import settings

from debusine.artifacts.models import BareDataCategory
from debusine.client.models import LookupChildType, LookupResultType
from debusine.db.models import (
    Artifact,
    Collection,
    CollectionItem,
    FileInArtifact,
    User,
    WorkRequest,
    Workspace,
)
from debusine.db.models.lookups import LookupResult
from debusine.server.tasks import BaseServerTask
from debusine.server.tasks.models import CopyCollectionItemsData


class CannotCopy(Exception):
    """A given source item cannot be copied to a given target collection."""


class CopyCollectionItems(BaseServerTask[CopyCollectionItemsData]):
    """Task that copies items into target collections."""

    TASK_VERSION = 1

    @staticmethod
    def copy_artifact(
        artifact: Artifact, target_workspace: Workspace
    ) -> Artifact:
        """Copy an artifact to a new workspace."""
        new_artifact = Artifact.objects.create(
            category=artifact.category,
            workspace=target_workspace,
            data=artifact.data,
            expiration_delay=artifact.expiration_delay,
            # The new artifact has a fresh created_at, but we keep
            # created_by and created_by_work_request from the old artifact
            # since that seems more likely to be useful.
            created_by=artifact.created_by,
            created_by_work_request=artifact.created_by_work_request,
            original_artifact=artifact,
        )
        for file_in_artifact in artifact.fileinartifact_set.select_related(
            "file"
        ):
            if not file_in_artifact.complete:
                raise CannotCopy(
                    f"Cannot copy incomplete {file_in_artifact.path!r} from "
                    f"artifact ID {artifact.id}"
                )
            file = file_in_artifact.file
            if not target_workspace.scope.download_file_stores(file).exists():
                # We must copy the file contents.
                try:
                    source_file_backend = (
                        artifact.workspace.scope.download_file_backend(file)
                    )
                except IndexError:
                    raise CannotCopy(
                        f"Cannot copy {file_in_artifact.path!r} from artifact "
                        f"ID {artifact.id}: not in any available file store"
                    )
                target_file_backend = (
                    target_workspace.scope.upload_file_backend(file)
                )
                with (
                    source_file_backend.get_stream(file) as source_file,
                    tempfile.NamedTemporaryFile(
                        prefix="file-copying-",
                        dir=settings.DEBUSINE_UPLOAD_DIRECTORY,
                    ) as temp_file,
                ):
                    shutil.copyfileobj(source_file, temp_file)
                    temp_file.flush()
                    target_file_backend.add_file(Path(temp_file.name), file)
            FileInArtifact.objects.create(
                artifact=new_artifact,
                path=file_in_artifact.path,
                file=file,
                complete=True,
                content_type=file_in_artifact.content_type,
            )
        return new_artifact

    def copy_item(
        self,
        source_item: LookupResult,
        target_collection: Collection,
        *,
        unembargo: bool = False,
        replace: bool = False,
        name_template: str | None = None,
        variables: dict[str, Any] | None = None,
        user: User,
        workflow: WorkRequest | None = None,
    ) -> None:
        """Copy a single item into a target collection."""
        match source_item.result_type:
            case LookupResultType.BARE:
                assert source_item.collection_item is not None
                source_workspace = (
                    source_item.collection_item.parent_collection.workspace
                )
                reference_data = {}
            case LookupResultType.ARTIFACT:
                assert source_item.artifact is not None
                source_workspace = source_item.artifact.workspace
                reference_data = source_item.artifact.data
            case LookupResultType.COLLECTION:  # pragma: no cover
                raise CannotCopy("Cannot copy entire collections")
            case _ as unreachable:
                raise AssertionError(
                    f"Unexpected lookup result type: {unreachable}"
                )

        target_workspace = target_collection.workspace

        if not unembargo:
            if not source_workspace.public and target_workspace.public:
                raise CannotCopy(
                    f"Copying from {source_workspace} to {target_workspace} "
                    f"requires unembargo=True"
                )

        merged_variables = (
            source_item.collection_item.data
            if source_item.collection_item is not None
            else {}
        ) | (variables or {})
        try:
            expanded_variables = CollectionItem.expand_variables(
                merged_variables, reference_data
            )
        except (KeyError, ValueError):
            raise CannotCopy(f"Cannot expand variables: {variables}")

        if name_template is not None:
            item_name = CollectionItem.expand_name(
                name_template, expanded_variables
            )
            item_variables = None
        else:
            item_name = None
            item_variables = expanded_variables

        match source_item.result_type:
            case LookupResultType.BARE:
                assert source_item.collection_item is not None
                self.append_to_log_file(
                    "copy-collection-items.log",
                    [
                        f"Copying to {target_collection}: "
                        f"{source_item.collection_item}"
                    ],
                )
                target_collection.manager.add_bare_data(
                    category=BareDataCategory(
                        source_item.collection_item.category
                    ),
                    user=user,
                    workflow=workflow,
                    # To match update-collections-with-data, pass variables
                    # even if they were used to compute the item name.
                    data=expanded_variables,
                    name=item_name,
                    replace=replace,
                )
            case LookupResultType.ARTIFACT:
                assert source_item.artifact is not None
                self.append_to_log_file(
                    "copy-collection-items.log",
                    [f"Copying to {target_collection}: {source_item.artifact}"],
                )
                new_artifact = self.copy_artifact(
                    source_item.artifact, target_workspace
                )
                target_collection.manager.add_artifact(
                    new_artifact,
                    user=user,
                    workflow=workflow,
                    variables=item_variables,
                    name=item_name,
                    replace=replace,
                )
            case _ as unreachable:
                raise AssertionError(
                    f"Unexpected lookup result type: {unreachable}"
                )

    @override
    def _execute(self) -> WorkRequest.Results:
        """Execute the task."""
        target_collection_lookups = {
            copies.target_collection for copies in self.data.copies
        }
        target_collections = {
            lookup: self.work_request.lookup_single(
                lookup=lookup, expect_type=LookupChildType.COLLECTION
            ).collection
            for lookup in target_collection_lookups
        }

        for copies in self.data.copies:
            source_items = self.work_request.lookup_multiple(
                copies.source_items, expect_type=LookupChildType.ANY
            )
            for source_item in source_items:
                try:
                    self.copy_item(
                        source_item,
                        target_collections[copies.target_collection],
                        unembargo=copies.unembargo,
                        replace=copies.replace,
                        name_template=copies.name_template,
                        variables=copies.variables,
                        user=self.work_request.created_by,
                        workflow=self.work_request.parent,
                    )
                except Exception as e:
                    self.append_to_log_file(
                        "copy-collection-items.log", [f"Copy failed: {e}"]
                    )
                    raise

        return WorkRequest.Results.SUCCESS

    @override
    def get_task_configuration_subject_context(
        self,
    ) -> tuple[str | None, str | None]:
        # Compute dynamic task to extract subject and
        # configuration_context
        # TODO: these values can be computed in a separate methods if the rest
        # TODO: of compute_dynamic_data becomes too expensive to run twice
        early_dynamic_task_data = self.compute_dynamic_data()
        return (
            early_dynamic_task_data.subject,
            early_dynamic_task_data.configuration_context,
        )

    @override
    def get_label(self) -> str:
        """Return the task label."""
        target_collections = ", ".join(
            str(copies.target_collection) for copies in self.data.copies
        )
        return f"copy to {target_collections}"
