# 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 abc
import os.path
from enum import StrEnum
from functools import cached_property
from typing import Any, assert_never

from django.db.models import QuerySet
from django.db.models.functions import Lower
from django.http import Http404, HttpRequest, StreamingHttpResponse
from django.http.response import HttpResponse, HttpResponseBase
from django.shortcuts import get_object_or_404
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

from debusine.artifacts.models import ArtifactCategory
from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    FileInArtifact,
    Workspace,
)
from debusine.server.tar import TarArtifact
from debusine.server.views import (
    ArtifactInPublicWorkspace,
    IsTokenAuthenticated,
    IsUserAuthenticated,
    ValidatePermissionsMixin,
)
from debusine.web.forms import ArtifactForm
from debusine.web.views import sidebar, ui_shortcuts
from debusine.web.views.base import CreateViewBase, DetailViewBase
from debusine.web.views.base_rightbar import RightbarUIView
from debusine.web.views.files import (
    FileDownloadMixin,
    FileUI,
    FileWidget,
    PathMixin,
)
from debusine.web.views.http_errors import HttpError400, catch_http_errors
from debusine.web.views.sidebar import SidebarItem
from debusine.web.views.utils import format_yaml


class ArtifactView(RightbarUIView, abc.ABC):
    """Base view for UIs showing an artifact or part of it."""

    @abc.abstractmethod
    def get_artifact(self) -> Artifact:
        """Return the current artifact."""

    def get_workspace(self) -> Workspace | None:
        """Return the current workspace (if any)."""
        return self.artifact.workspace

    @cached_property
    def artifact(self) -> Artifact:
        """Artifact for this view."""
        return self.get_artifact()

    def get_sidebar_items(self) -> list[SidebarItem]:
        """Return a list of sidebar items."""
        items = super().get_sidebar_items()
        artifact = self.artifact
        items.append(sidebar.create_artifact_category(artifact))
        items.append(sidebar.create_workspace(artifact.workspace))
        items.append(
            sidebar.create_work_request(
                artifact.created_by_work_request, link=True
            )
        )
        items.append(sidebar.create_user(artifact.created_by, context=artifact))
        items.append(sidebar.create_created_at(artifact.created_at))
        items.append(sidebar.create_expire_at(artifact.expire_at))
        return items


class ArtifactDetailView(
    ArtifactView,
    ValidatePermissionsMixin,
    DetailViewBase[Artifact],
):
    """Display an artifact and its file(s)."""

    model = Artifact
    pk_url_kwarg = "artifact_id"
    template_name = "web/artifact-detail.html"
    context_object_name = "artifact"
    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_artifact(self) -> Artifact:
        """Artifact for this view."""
        return self.object

    def get_queryset(self) -> QuerySet[Artifact]:
        """Add the select_related we need to the queryset."""
        return super().get_queryset().select_related("workspace")

    def get_title(self) -> str:
        """Return the page title."""
        return f"Artifact {self.object.get_label()}"

    def get_main_ui_shortcuts(self) -> list[ui_shortcuts.UIShortcut]:
        """Return a list of UI shortcuts for this view."""
        shortcuts = super().get_main_ui_shortcuts()

        # TODO: I am not sure yet how to architecture category-dependenty
        # behaviour, and I'll postpone doing it until we have more cases that
        # should make it more obvious which way to refactor the code to avoid
        # a scattering of UI-related category matches
        if (work_request := self.object.created_by_work_request) and (
            self.object.category != ArtifactCategory.PACKAGE_BUILD_LOG
        ):
            try:
                build_log = Artifact.objects.filter(
                    created_by_work_request=work_request,
                    category=ArtifactCategory.PACKAGE_BUILD_LOG,
                ).latest("created_at")
                shortcuts.append(ui_shortcuts.create_artifact_view(build_log))
            except Artifact.DoesNotExist:
                pass
        shortcuts.append(ui_shortcuts.create_artifact_download(self.object))

        return shortcuts

    @cached_property
    def file_list(self) -> list[FileInArtifact]:
        """Return the files in the artifact."""
        file_list: list[FileInArtifact] = []
        queryset = (
            FileInArtifact.objects.filter(artifact=self.object)
            .select_related("file")
            .order_by(Lower("path"))
        )
        for file_in_artifact in queryset:
            file_list.append(file_in_artifact)
            setattr(
                file_in_artifact,
                "basename",
                os.path.basename(file_in_artifact.path),
            )
        return file_list

    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
        """Return context for this view."""
        context = super().get_context_data(**kwargs)
        for file_in_artifact in self.file_list:
            self.add_object_ui_shortcuts(
                file_in_artifact,
                ui_shortcuts.create_file_view(file_in_artifact),
                ui_shortcuts.create_file_view_raw(file_in_artifact),
                ui_shortcuts.create_file_download(file_in_artifact),
            )

        context["artifact_label"] = self.object.get_label()
        context["file_list"] = self.file_list
        context["artifact_data"] = format_yaml(self.object.data)
        context["reverse_extends"] = self.object.targeted_by.filter(
            type=ArtifactRelation.Relations.EXTENDS
        )

        if len(self.file_list) == 1:
            context["file"] = FileWidget.create(self.file_list[0])

        return context


class ArtifactDetailFileView(
    ValidatePermissionsMixin,
    ArtifactView,
    DetailViewBase[FileInArtifact],
):
    """Display a file from an artifact."""

    model = FileInArtifact
    template_name = "web/fileinartifact-detail.html"
    permission_denied_message = (
        "Non-public artifact: you might need to login "
        "or make a request with a valid Token header"
    )
    pk_url_kwarg = "file_in_artifact_id"

    permission_classes = [
        IsUserAuthenticated | IsTokenAuthenticated | ArtifactInPublicWorkspace
    ]

    def get_queryset(self) -> QuerySet[FileInArtifact]:
        """Get the queryset."""
        return (
            super()
            .get_queryset()
            .filter(
                artifact_id=self.kwargs["artifact_id"], path=self.kwargs["path"]
            )
            .select_related("artifact")
        )

    def get_artifact(self) -> Artifact:
        """Artifact for this view."""
        return self.object.artifact

    def get_title(self) -> str:
        """Return the page title."""
        return self.object.path

    def get_main_ui_shortcuts(self) -> list[ui_shortcuts.UIShortcut]:
        """Return a list of UI shortcuts for this view."""
        shortcuts = super().get_main_ui_shortcuts()
        shortcuts.append(ui_shortcuts.create_file_view_raw(self.object))
        shortcuts.append(ui_shortcuts.create_file_download(self.object))
        shortcuts.append(
            ui_shortcuts.create_artifact_view(self.object.artifact)
        )
        return shortcuts

    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
        """Add the file to display."""
        context = super().get_context_data(**kwargs)
        context["file"] = FileWidget.create(self.object)
        return context


class ArtifactDetailFileViewRaw(
    ValidatePermissionsMixin,
    PathMixin,
    FileDownloadMixin,
    View,
):
    """Download a file from an artifact, displayed inline."""

    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(
        self, request: HttpRequest, **kwargs: Any  # noqa: U100
    ) -> HttpResponseBase:
        """Download file with disposition inline."""
        file_in_artifact = get_object_or_404(
            FileInArtifact,
            artifact_id=self.kwargs["artifact_id"],
            id=self.kwargs["file_in_artifact_id"],
            path=self.path.rstrip("/"),
        )
        ui_info = FileUI.from_file_in_artifact(file_in_artifact)
        return self.stream_file(file_in_artifact, ui_info, download=False)


class DownloadFormat(StrEnum):
    """Available download formats."""

    AUTO = "auto"
    TAR_GZ = "tar.gz"


class DownloadPathView(
    ValidatePermissionsMixin, PathMixin, FileDownloadMixin, 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
    ]

    @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 download_format(self) -> DownloadFormat:
        """Return the download format."""
        if (archive := self.request.GET.get("archive", None)) is None:
            return DownloadFormat.AUTO
        try:
            return DownloadFormat(archive)
        except ValueError:
            values = ", ".join(e.value for e in DownloadFormat)
            raise HttpError400(
                f"Invalid archive parameter: {archive!r}. Supported: {values}"
            )

    @catch_http_errors
    def get(self, request: HttpRequest, **kwargs: Any) -> HttpResponseBase:
        """Download files from the artifact in .tar.gz."""
        if not self.path:
            # Download the whole artifact
            return self._get_artifact()

        try:
            # Try to return a file
            file_in_artifact = self.artifact.fileinartifact_set.get(
                path=self.path.rstrip("/")
            )
            return self._get_file(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.path
        ).exists()
        if directory_exists:
            return self._get_directory()

        # 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_artifact(self) -> HttpResponseBase:
        match self.download_format:
            case DownloadFormat.AUTO:
                if self.artifact.fileinartifact_set.count() == 1:
                    file_in_artifact = self.artifact.fileinartifact_set.first()
                    assert file_in_artifact is not None
                    return self._get_file(file_in_artifact)
                else:
                    return self._get_directory_tar_gz()
            case DownloadFormat.TAR_GZ:
                return self._get_directory_tar_gz()
            case _ as unreachable:
                assert_never(unreachable)

    def _get_directory(self) -> HttpResponseBase:
        match self.download_format:
            case DownloadFormat.TAR_GZ:
                return self._get_directory_tar_gz()
            case _:
                raise HttpError400(
                    "archive argument needed when downloading directories"
                )

    def _get_directory_tar_gz(self) -> HttpResponseBase:
        # 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.path == "" else self.path,
            ),
            status=status.HTTP_200_OK,
        )
        response["Content-Type"] = "application/octet-stream"

        directory_name = ""
        if self.path != "":
            directory_name = self.path.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, file_in_artifact: FileInArtifact) -> HttpResponseBase:
        """Return a response that streams the_given file."""
        ui_info = FileUI.from_file_in_artifact(file_in_artifact)
        return self.stream_file(file_in_artifact, ui_info, download=True)


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},
        )

    def form_valid(self, form: ArtifactForm) -> HttpResponse:
        """Save the associated model, then clean up."""
        try:
            return super().form_valid(form)
        finally:
            form.cleanup()

    def form_invalid(self, form: ArtifactForm) -> HttpResponse:
        """Render the invalid form, then clean up."""
        try:
            return super().form_invalid(form)
        finally:
            form.cleanup()
