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

"""debusine artifact views."""

import io
import mmap
import os.path
from collections.abc import Iterable
from functools import cached_property
from pathlib import Path, PurePath
from typing import Any

from django.db.models import QuerySet
from django.db.models.functions import Lower
from django.http import FileResponse, Http404, StreamingHttpResponse
from django.http.response import HttpResponseBase
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.http import http_date
from django.views.generic import View

from rest_framework import status

import yaml

from debusine.db.models import (
    Artifact,
    File,
    FileInArtifact,
)
from debusine.server.tar import TarArtifact
from debusine.server.views import (
    ArtifactInPublicWorkspace,
    IsTokenAuthenticated,
    IsUserAuthenticated,
    ProblemResponse,
    ValidatePermissionsMixin,
)
from debusine.utils import parse_range_header
from debusine.web.forms import ArtifactForm
from debusine.web.views.base import (
    CreateViewBase,
    DetailViewBase,
    ListViewBase,
)
from debusine.web.views.mixins import PaginationMixin


class ArtifactDetailView(
    ValidatePermissionsMixin, PaginationMixin, ListViewBase[FileInArtifact]
):
    """Display an artifact and its files."""

    model = FileInArtifact
    template_name = "web/fileinartifact-list.html"
    ordering = "path"
    paginate_by = 50
    permission_denied_message = (
        "Non-public artifact: you might need to login "
        "or make a request with a valid Token header"
    )

    permission_classes = [
        IsUserAuthenticated | IsTokenAuthenticated | ArtifactInPublicWorkspace
    ]

    @cached_property
    def artifact(self) -> Artifact:
        """Return the artifact for this view."""
        artifact_id = self.kwargs["artifact_id"]
        try:
            return Artifact.objects.get(pk=artifact_id)
        except Artifact.DoesNotExist:
            raise Http404(f"Artifact {artifact_id} does not exist")

    def get_queryset(self) -> QuerySet[FileInArtifact]:
        """Return the QuerySet for the current view."""
        return (
            self.model.objects.filter(artifact=self.artifact)
            .select_related("file")
            .order_by(Lower("path"))
        )

    @staticmethod
    def _get_file_list_context(
        fileinartifact: FileInArtifact,
    ) -> dict[str, Any]:
        """Get context data for a :class:`FileInArtifact`."""
        return {
            "path": fileinartifact.path,
            "name": PurePath(fileinartifact.path).name,
            "size": fileinartifact.file.size,
            "hash": fileinartifact.file.hash_digest.hex(),
        }

    def get_context_data(self, *args, **kwargs):
        """Return context for this view."""
        context = super().get_context_data(*args, **kwargs)
        context["file_list"] = [
            self._get_file_list_context(obj) for obj in self.object_list
        ]
        context["artifact"] = self.artifact
        context["hash_algorithm"] = File.current_hash_algorithm
        context["data_yaml"] = yaml.safe_dump(self.artifact.data)
        context["download_artifact_tar_gz_url"] = (
            reverse(
                "artifacts:download",
                kwargs={"artifact_id": self.artifact.id},
            )
            + "?archive=tar.gz"
        )
        return context


class DownloadPathView(ValidatePermissionsMixin, View):
    """View to download an artifact (in .tar.gz or list its files)."""

    permission_denied_message = (
        "Non-public artifact: you might need to login "
        "or make a request with a valid Token header"
    )
    permission_classes = [
        IsUserAuthenticated | IsTokenAuthenticated | ArtifactInPublicWorkspace
    ]

    @staticmethod
    def normalise_path(path: str) -> str:
        """
        Normalise a path used as a subdirectory prefix.

        It will also constrain paths not to point above themselves by extra ../
        components
        """
        path = os.path.normpath(path).strip("/")
        while path.startswith("../"):
            path = path[3:]
        if path in ("", ".", ".."):
            return "/"
        return f"/{path}/"

    @cached_property
    def artifact(self) -> Artifact:
        """Return the artifact for this view."""
        artifact_id = self.kwargs["artifact_id"]
        try:
            return Artifact.objects.get(pk=artifact_id)
        except Artifact.DoesNotExist:
            raise Http404(f"Artifact {artifact_id} does not exist")

    @cached_property
    def subdirectory(self) -> str:
        """Return the current subdirectory as requested by the user."""
        path = self.kwargs.get("path")
        if not path:
            return "/"
        return self.normalise_path(path)

    def get(self, request, **kwargs):
        """Download files from the artifact in .tar.gz."""
        archive = request.GET.get("archive", None)
        path = self.kwargs.get("path")

        if archive is not None and archive != "tar.gz":
            context = {
                "error": f'Invalid archive parameter: "{archive}". '
                f'Supported: "tar.gz"'
            }
            return TemplateResponse(
                request, "400.html", context, status=status.HTTP_400_BAD_REQUEST
            )

        if path is None:
            # List files or download .tar.gz of the whole artifact
            return self._get_directory(archive)

        try:
            # Try to return a file
            file_in_artifact = self.artifact.fileinartifact_set.get(path=path)
            return self._get_file(request.headers, file_in_artifact)
        except FileInArtifact.DoesNotExist:
            # No file exist
            pass

        # Try to return a .tar.gz / list of files for the directory
        directory_exists = self.artifact.fileinartifact_set.filter(
            path__startswith=self.subdirectory[1:]
        ).exists()
        if directory_exists:
            return self._get_directory(archive)

        # Neither a file nor directory existed, HTTP 404
        context = {
            "error": f'Artifact {self.artifact.id} does not have any file '
            f'or directory for "{self.kwargs["path"]}"'
        }
        return TemplateResponse(
            request, "404.html", context, status=status.HTTP_404_NOT_FOUND
        )

    def _get_directory(self, archive: str | None):
        match archive:
            case "tar.gz":
                return self._get_directory_tar_gz()
            case _:
                context = {
                    "error": "archive argument needed"
                    " when downloading directories"
                }
                return TemplateResponse(
                    self.request,
                    "400.html",
                    context,
                    status=status.HTTP_400_BAD_REQUEST,
                )

    def _get_directory_tar_gz(self):
        # Currently due to https://code.djangoproject.com/ticket/33735
        # the .tar.gz file is kept in memory by Django (asgi) and the
        # first byte to be sent to the client happens when the .tar.gz has
        # been all generated. When the Django ticket is fixed the .tar.gz
        # will be served as soon as a file is added and the memory usage will
        # be reduced to TarArtifact._chunk_size_mb

        response = StreamingHttpResponse(
            TarArtifact(
                self.artifact,
                None if self.subdirectory == "/" else self.subdirectory[1:],
            ),
            status=status.HTTP_200_OK,
        )
        response["Content-Type"] = "application/octet-stream"

        directory_name = ""
        if self.subdirectory != "/":
            directory_name = self.subdirectory[1:].removesuffix("/")
            directory_name = directory_name.replace("/", "_")
            directory_name = f"-{directory_name}"

        filename = f"artifact-{self.artifact.id}{directory_name}.tar.gz"
        disposition = f'attachment; filename="{filename}"'
        response["Content-Disposition"] = disposition
        response["Last-Modified"] = http_date(
            self.artifact.created_at.timestamp()
        )

        return response

    def _get_file(self, headers, file_in_artifact: FileInArtifact):
        """Download path_file from artifact_id."""
        try:
            content_range = parse_range_header(headers)
        except ValueError as exc:
            # It returns ProblemResponse because ranges are not used
            # by end users directly
            return ProblemResponse(str(exc))

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

        # Ignore func-returns-value for now: LocalFileBackend.get_url always
        # returns None, but the backend might be something else in future.
        url = file_backend.get_url(
            file_in_artifact.file
        )  # type: ignore[func-returns-value]
        if url is not None:
            # The client can download the file from the backend
            return redirect(url)

        with file_backend.get_stream(file_in_artifact.file) as file:
            file_size = file_in_artifact.file.size
            status_code: int
            if content_range is None:
                # Whole file
                status_code = status.HTTP_200_OK
                start = 0
                end = file_size - 1
            else:
                # Part of a file
                status_code = status.HTTP_206_PARTIAL_CONTENT
                start = content_range["start"]
                end = content_range["end"]

                # It returns ProblemResponse because ranges are not used
                # by end users directly
                if start > file_size:
                    return ProblemResponse(
                        f"Invalid Content-Range start: {start}. "
                        f"File size: {file_size}"
                    )

                elif end >= file_size:
                    return ProblemResponse(
                        f"Invalid Content-Range end: {end}. "
                        f"File size: {file_size}"
                    )

            # Use mmap:
            # - No support for content-range or file chunk in Django
            #   as of 2023, so create filelike object of the right chunk
            # - Prevents FileResponse.file_to_stream.name from taking
            #   precedence over .filename and break mimestype
            file_partitioned: Iterable[object]
            if file_size == 0:
                # cannot mmap an empty file
                file_partitioned = io.BytesIO(b"")
            else:
                file_partitioned = mmap.mmap(
                    file.fileno(), end + 1, prot=mmap.PROT_READ
                )
                file_partitioned.seek(start)

            filename = Path(file_in_artifact.path)

            response = FileResponse(
                file_partitioned,
                filename=filename.name,
                status=status_code,
            )

            response["Accept-Ranges"] = "bytes"
            response["Content-Length"] = end - start + 1
            if file_size > 0:
                response["Content-Range"] = f"bytes {start}-{end}/{file_size}"

            self._set_content_type(filename, response)

            return response

    @staticmethod
    def _set_content_type(filename: Path, response: HttpResponseBase):
        """
        If filename could be viewed in the browser: change content-type.

        Set Content-Type = text/plain for .log, .build or .changes files.
        By default, (Django implementation use Python's mimetypes.guess_type)
        these files' Content-Type is application/octet-stream, but it is
        preferred to view them in the browser (so text/plain).
        """
        if filename.suffix in (
            ".txt",
            ".log",
            ".build",
            ".buildinfo",
            ".changes",
            ".sources",
        ):
            response["Content-Type"] = "text/plain; charset=utf-8"
        elif filename.suffix == '.md':
            response["Content-Type"] = "text/markdown; charset=utf-8"


class CreateArtifactView(
    ValidatePermissionsMixin, CreateViewBase[Artifact, ArtifactForm]
):
    """View to create an artifact (uploading files)."""

    template_name = "web/artifact-create.html"
    form_class = ArtifactForm

    permission_denied_message = (
        "You need to be authenticated to create an Artifact"
    )
    permission_classes = [IsUserAuthenticated]

    def get_form_kwargs(self) -> dict[str, Any]:
        """Extend the default kwarg arguments: add "user"."""
        kwargs = super().get_form_kwargs()
        kwargs["user"] = self.request.user
        return kwargs

    def get_success_url(self):
        """Redirect to the view to see the created artifact."""
        return reverse(
            "artifacts:detail",
            kwargs={"artifact_id": self.object.id},
        )


class BuildLogView(ValidatePermissionsMixin, DetailViewBase[Artifact]):
    """Nice display of a build log."""

    model = Artifact
    pk_url_kwarg = "artifact_id"
    context_object_name = "buildlog"
    template_name = "web/build-log-detail.html"
    permission_denied_message = (
        "Non-public artifact: you might need to login "
        "or make a request with a valid Token header"
    )

    permission_classes = [
        IsUserAuthenticated | IsTokenAuthenticated | ArtifactInPublicWorkspace
    ]

    def get_context_data(self, **kwargs):
        """Add the build log contents."""
        context = super().get_context_data(**kwargs)
        file_in_artifact = FileInArtifact.objects.filter(
            artifact=self.object
        ).first()
        context["path"] = file_in_artifact.path
        context["file_in_artifact"] = file_in_artifact
        workspace = self.object.workspace
        file_backend = workspace.default_file_store.get_backend_object()
        with file_backend.get_stream(file_in_artifact.file) as lines:
            context["lines"] = list(
                (lineno, line.decode())
                for lineno, line in enumerate(lines, start=1)
            )
        return context
