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

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

import binascii
import copy
import logging
import tempfile
from pathlib import Path
from typing import Generic, TypeVar

from django.conf import settings
from django.db import transaction
from django.db.models import Model
from django.http import (
    HttpResponse,
)
from django.utils import timezone

from rest_framework import status, views
from rest_framework.parsers import BaseParser, JSONParser
from rest_framework.request import Request
from rest_framework.response import Response

from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    DEFAULT_WORKSPACE_NAME,
    File,
    FileInArtifact,
    FileInStore,
    FileStore,
    FileUpload,
    Token,
    User,
    WorkRequest,
    Workspace,
)
from debusine.server.serializers import (
    ArtifactRelationResponseSerializer,
    ArtifactSerializer,
    ArtifactSerializerResponse,
)
from debusine.server.views.base import (
    CreateAPIViewBase,
    DestroyAPIViewBase,
    IsTokenAuthenticated,
    IsTokenUserAuthenticated,
    IsWorkerAuthenticated,
    ListAPIViewBase,
    ProblemResponse,
)
from debusine.utils import parse_content_range_header

logger = logging.getLogger(__name__)


class ArtifactView(views.APIView):
    """View used to get or create artifacts."""

    permission_classes = [IsTokenAuthenticated]

    def get(self, request, artifact_id: int):
        """Return information of artifact_id."""
        try:
            artifact = Artifact.objects.get(id=artifact_id)
        except Artifact.DoesNotExist:
            return ProblemResponse(
                f"Artifact {artifact_id} not found",
                status_code=status.HTTP_404_NOT_FOUND,
            )

        artifact_response = ArtifactSerializerResponse.from_artifact(
            artifact, request
        )

        return Response(artifact_response.data, status=status.HTTP_200_OK)

    @transaction.atomic
    def post(self, request: Request) -> HttpResponse:
        """Create a new artifact."""
        data = request.data.copy()

        # "workspace" might not be in data, but it is a mandatory field
        # in the serializer.
        # This view might change in the future and use a different "workspace"
        # depending on the user/token that is creating the Artifact.
        # Currently, it uses the DEFAULT_WORKSPACE_NAME
        if "workspace" not in data:
            data["workspace"] = DEFAULT_WORKSPACE_NAME

        artifact_deserialized = ArtifactSerializer(data=data)
        if not artifact_deserialized.is_valid():
            return ProblemResponse(
                "Cannot deserialize artifact",
                validation_errors=artifact_deserialized.errors,
            )

        workspace_name = artifact_deserialized["workspace"].value
        try:
            workspace = Workspace.objects.get(name=workspace_name)
        except Workspace.DoesNotExist:
            return ProblemResponse(
                f'Workspace "{workspace_name}" cannot be found'
            )

        work_request_id: int | None = artifact_deserialized.validated_data.get(
            "work_request"
        )
        created_by: User | None = None

        if work_request_id is not None:
            if not IsWorkerAuthenticated().has_permission(request, self):
                return ProblemResponse(
                    "work_request field can only be used by a request with a "
                    "token from a Worker",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

            try:
                WorkRequest.objects.get(id=work_request_id)
            except WorkRequest.DoesNotExist:
                return ProblemResponse(
                    f"WorkRequest {work_request_id} does not exist"
                )
        else:
            if not IsTokenUserAuthenticated().has_permission(request, self):
                return ProblemResponse(
                    "If work_request field is empty the WorkRequest must be "
                    "created using a Token associated to a user",
                    status_code=status.HTTP_403_FORBIDDEN,
                )
            token_key = request.headers.get("token")
            # The user is authenticated, so there must be a token
            assert token_key is not None
            created_by = Token.objects.get(
                hash=Token._generate_hash(token_key)
            ).user

        artifact = Artifact.objects.create(
            category=artifact_deserialized["category"].value,
            workspace=workspace,
            data=artifact_deserialized["data"].value,
            created_by_work_request_id=work_request_id,
            expiration_delay=artifact_deserialized["expiration_delay"].value,
            created_by=created_by,
        )

        for path, file_deserialized in artifact_deserialized[
            "files"
        ].value.items():
            file_size = file_deserialized["size"]
            hash_digest = binascii.unhexlify(
                file_deserialized["checksums"]["sha256"]
            )

            fileobj, created = File.get_or_create(
                hash_digest=hash_digest,
                size=file_size,
            )

            file_in_artifact = FileInArtifact.objects.create(
                artifact=artifact, file=fileobj, path=path
            )

            if (
                created or not artifact.workspace.is_file_in_workspace(fileobj)
            ) and file_size == 0:
                # Create the file upload and add the file in the store
                temp_empty_file = tempfile.NamedTemporaryFile(
                    prefix="file-uploading-empty-",
                    dir=settings.DEBUSINE_UPLOAD_DIRECTORY,
                    delete=False,
                )
                temp_empty_file.close()
                file_upload = FileUpload.objects.create(
                    file_in_artifact=file_in_artifact,
                    path=Path(temp_empty_file.name).name,
                )
                UploadFileView.create_file_in_storage(file_upload)

        artifact_response = ArtifactSerializerResponse.from_artifact(
            artifact, request
        )
        return Response(artifact_response.data, status=status.HTTP_201_CREATED)


class FileUploadParser(BaseParser):
    """View used to upload files into an artifact."""

    media_type = "*/*"

    @staticmethod
    def _raise_error_if_gap_in_file(content_range, temporary_file_path):
        if content_range is None:
            return

        uploaded_size = Path(temporary_file_path).stat().st_size
        if uploaded_size < content_range["start"]:
            raise ValueError(
                f"Server had received {uploaded_size} bytes of the file. "
                f"New upload starts at {content_range['start']}. "
                f"Please continue from {uploaded_size}"
            )

    def parse(self, stream, media_type=None, parser_context=None):  # noqa: U100
        """Upload a file to an artifact."""
        file_in_artifact = parser_context["request"].file_in_artifact

        range_start = 0
        range_end = None

        content_range = parse_content_range_header(
            parser_context["request"].headers
        )

        if content_range is not None:
            range_start = content_range["start"]
            range_end = content_range["end"]

        if range_start > (file_size := file_in_artifact.file.size):
            raise ValueError(
                f"Range start ({range_start}) is greater than "
                f"file size ({file_size})"
            )

        try:
            file_upload = FileUpload.objects.get(
                file_in_artifact=file_in_artifact
            )
            temporary_file_path = file_upload.absolute_file_path()
            self._raise_error_if_gap_in_file(content_range, temporary_file_path)
            uploads_file = open(temporary_file_path, "r+b")
        except FileUpload.DoesNotExist:
            uploads_file = tempfile.NamedTemporaryFile(
                prefix='file-uploading-',
                dir=settings.DEBUSINE_UPLOAD_DIRECTORY,
                delete=False,
                mode="a+b",
            )
            file_upload = FileUpload.objects.create(
                file_in_artifact=file_in_artifact,
                path=Path(uploads_file.name).name,
            )

        uploads_file.seek(range_start)

        bytes_received = 0
        # DATA_UPLOAD_MAX_MEMORY_SIZE: arbitrary size that should make sense
        # that can be dealt with
        while chunk := stream.read(settings.DATA_UPLOAD_MAX_MEMORY_SIZE):
            bytes_received += len(chunk)

            if range_start + bytes_received > file_size:
                raise ValueError(
                    f"Cannot process range: attempted to write after end "
                    f"of the file. Range start: {range_start} "
                    f"Received: {bytes_received} File size: {file_size}"
                )

            uploads_file.write(chunk)

        if (
            range_end is not None
            and (expected_bytes := range_end - range_start + 1)
            != bytes_received
        ):
            raise ValueError(
                f"Expected {expected_bytes} bytes (based on range header: "
                f"end-start+1). Received: {bytes_received} bytes"
            )

        uploads_file.close()

        file_upload.last_activity_at = timezone.now()
        file_upload.save()

        return file_upload


class UploadFileView(views.APIView):
    """View to upload files."""

    parser_classes = [FileUploadParser]
    permission_classes = [IsTokenAuthenticated]

    @staticmethod
    def _range_header(start, end):
        return {"Range": f"bytes={start}-{end}"}

    @classmethod
    def _file_status_response(cls, file_in_artifact, range_file_size):
        """
        Return response with the status of a file (completed or missing range).

        :param file_in_artifact: FileInArtifact that is being uploaded
        :param range_file_size: if it's "*" returns the total file size,
          if it contains a number and does not match the expected
          the server returns HTTP 400 Bad request
        """
        file_upload = getattr(file_in_artifact, "fileupload", None)

        if file_upload is None:
            return Response(
                status=status.HTTP_206_PARTIAL_CONTENT,
                headers=cls._range_header(0, 0),
            )

        file_in_db_size = file_upload.file_in_artifact.file.size
        if range_file_size != "*" and range_file_size != file_in_db_size:
            # Client reported a size in Content-Range that is not
            # what was expected
            return ProblemResponse(
                "Invalid file size in Content-Range header. "
                f"Expected {file_in_db_size} "
                f"received {range_file_size}"
            )

        current_size = FileUpload.current_size(
            artifact=file_in_artifact.artifact,
            path_in_artifact=file_in_artifact.path,
        )

        if current_size == file_in_db_size:
            # No missing parts: file upload is already completed
            return Response(status=status.HTTP_200_OK)
        else:
            # Return HTTP 206 with the Range: 0-{uploaded_size}
            # so the client can continue the upload
            return Response(
                status=status.HTTP_206_PARTIAL_CONTENT,
                headers=cls._range_header(0, current_size),
            )

    @transaction.atomic
    def put(self, request, artifact_id: int, file_path: str):
        """Receive file to be uploaded or request uploaded content-range."""
        try:
            file_in_artifact = _get_file_in_artifact_or_raise_value_error(
                artifact_id,
                file_path,
            )
        except ValueError as exc:
            return ProblemResponse(
                str(exc), status_code=status.HTTP_404_NOT_FOUND
            )

        try:
            content_range = parse_content_range_header(request.headers)
        except ValueError as exc:
            return ProblemResponse(str(exc))

        artifact = file_in_artifact.artifact

        if content_range is not None and content_range["start"] == "*":
            # Client would like to know the status of a file
            # (which parts have been uploaded)
            return self._file_status_response(
                file_in_artifact, content_range["size"]
            )

        if self._file_already_uploaded(file_in_artifact.file):
            return Response(status=status.HTTP_201_CREATED)

        request.file_in_artifact = file_in_artifact
        # request.data use the FileUploadParser
        try:
            file_upload: FileUpload = request.data
        except ValueError as value_error:
            return ProblemResponse(str(value_error))

        current_size = FileUpload.current_size(
            artifact=artifact, path_in_artifact=file_path
        )

        if current_size != file_upload.file_in_artifact.file.size:
            return Response(
                status=status.HTTP_200_OK,
                headers=self._range_header(0, current_size),
            )
        else:
            # File is uploaded: create the file in the storage
            # Will return HTTP 201 Created (or HTTP 409 Conflict if the
            # expected hash does not match, the client will need to re-upload)
            response = self.create_file_in_storage(file_upload)
            file_upload.delete()
            return response

    @staticmethod
    def create_file_in_storage(file_upload: FileUpload):
        """
        Add file_upload to the artifact's default store.

        Return Response: 201 Created if real hash and length with the
        expected hash and length match, 409 Conflict if no match
        (and deletes the FileUpload): the client need to upload the file again.
        """
        file_path = file_upload.absolute_file_path()

        hash_digest = File.calculate_hash(file_path)

        file_in_artifact = file_upload.file_in_artifact

        expected_file_hash_digest = file_in_artifact.file.hash_digest
        if file_in_artifact.file.hash_digest.hex() != hash_digest.hex():
            return ProblemResponse(
                "Invalid file hash. Expected: "
                f"{expected_file_hash_digest.hex()} "
                f"Received: {hash_digest.hex()}",
                status_code=status.HTTP_409_CONFLICT,
            )

        workspace = file_in_artifact.artifact.workspace
        file_backend = workspace.default_file_store.get_backend_object()

        file_backend.add_file(file_path, fileobj=file_in_artifact.file)

        return Response(status=status.HTTP_201_CREATED)

    @staticmethod
    def _file_already_uploaded(file):
        return FileInStore.objects.filter(
            store=FileStore.default(),
            file=file,
        ).exists()


def _get_file_in_artifact_or_raise_value_error(
    artifact_id: int, file_path: str
) -> FileInArtifact:
    try:
        artifact = Artifact.objects.get(id=artifact_id)
    except Artifact.DoesNotExist:
        raise ValueError(
            f"Artifact {artifact_id} does not exist",
        )

    try:
        file_in_artifact = FileInArtifact.objects.get(
            artifact=artifact, path=file_path
        )
    except FileInArtifact.DoesNotExist:
        raise ValueError(
            f'No file_path "{file_path}" for artifact {artifact.id}'
        )

    return file_in_artifact


T = TypeVar("T", bound=Model, covariant=True)


class GetOrCreateAPIView(CreateAPIViewBase[T], Generic[T]):
    """Class to create a new object (return 201 created) or return 200."""

    def create(self, request, *args, **kwargs):
        """
        Return the object with HTTP 201 (created) or HTTP 200 (already existed).

        It is a modification of CreateAPIView.create().

        The default behaviour in rest_framework when an object cannot be
        inserted because it already exists is to return HTTP 400. In
        GetOrCreateApiView it returns HTTP 200 and the client knows that
        the object that wanted to create was already in the database.
        """
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        try:
            obj = serializer.Meta.model.objects.get(**serializer.validated_data)
            response_status = status.HTTP_200_OK
            pk = obj.pk
        except serializer.Meta.model.DoesNotExist:
            self.perform_create(serializer)
            response_status = status.HTTP_201_CREATED
            pk = serializer.instance.pk

        headers = self.get_success_headers(serializer.data)

        response_data = copy.deepcopy(serializer.data)
        response_data["id"] = pk

        return Response(response_data, status=response_status, headers=headers)


class ArtifactRelationsView(
    GetOrCreateAPIView[ArtifactRelation],
    DestroyAPIViewBase[ArtifactRelation],
    ListAPIViewBase[ArtifactRelation],
):
    """Return, create and delete relations between artifacts."""

    serializer_class = ArtifactRelationResponseSerializer
    parser_classes = [JSONParser]
    pagination_class = None
    queryset = ArtifactRelation.objects

    def get(self, request, *args, **kwargs):
        """Return list of ArtifactRelations for artifact or target_artifact."""
        artifact_id = request.GET.get("artifact", None)
        target_artifact_id = request.GET.get("target_artifact", None)

        if artifact_id is not None:
            filter_kwargs = {"artifact_id": artifact_id}
            required_artifact_id = artifact_id
        elif target_artifact_id is not None:
            filter_kwargs = {"target_id": target_artifact_id}
            required_artifact_id = target_artifact_id
        else:
            return ProblemResponse(
                '"artifact" or "target_artifact" parameter are mandatory'
            )
        try:
            Artifact.objects.get(id=required_artifact_id)
        except Artifact.DoesNotExist:
            return ProblemResponse(
                f"Artifact {required_artifact_id} not found",
                status_code=status.HTTP_404_NOT_FOUND,
            )

        self.queryset = ArtifactRelation.objects.filter(**filter_kwargs)

        return super().get(request, *args, **kwargs)
