# Copyright 2022 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 views."""
import io
import mmap
from pathlib import Path
from typing import Optional, TYPE_CHECKING

from django.contrib.auth import views as auth_views
from django.db.models.functions import Lower
from django.http import FileResponse, StreamingHttpResponse
from django.http.response import HttpResponseBase
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.urls import reverse, reverse_lazy
from django.utils.http import http_date
from django.views.generic import (
    CreateView,
    DeleteView,
    ListView,
    TemplateView,
    UpdateView,
)

from rest_framework import status

import yaml

from debusine.db.models import (
    Artifact,
    File,
    FileInArtifact,
    Token,
    WorkRequest,
    Workspace,
)
from debusine.server.signon.views import SignonLogoutMixin
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, TokenForm
from debusine.web.views.mixins import PaginationMixin

if TYPE_CHECKING:
    CreateViewBase = CreateView
    DeleteViewBase = DeleteView
    ListViewBase = ListView
    UpdateViewBase = UpdateView
else:
    # Django's generic views don't support generic types at run-time yet.
    class _CreateViewBase:
        def __class_getitem__(*args):
            return CreateView

    class _DeleteViewBase:
        def __class_getitem__(*args):
            return DeleteView

    class _ListViewBase:
        def __class_getitem__(*args):
            return ListView

    class _UpdateViewBase:
        def __class_getitem__(*args):
            return UpdateView

    CreateViewBase = _CreateViewBase
    DeleteViewBase = _DeleteViewBase
    ListViewBase = _ListViewBase
    UpdateViewBase = _UpdateViewBase


class HomepageView(TemplateView):
    """Class for the homepage view."""

    template_name = "web/homepage.html"

    def get_context_data(self, *args, **kwargs):
        """Return context_data with work_request_list and workspace_list."""
        context = super().get_context_data(**kwargs)

        if self.request.user.is_authenticated:
            context["work_request_list"] = WorkRequest.objects.filter(
                created_by=self.request.user
            ).order_by("-created_at")[0:5]

            # Access to public and not public workspaces
            context["workspace_list"] = Workspace.objects.order_by("name")
        else:
            # Only public workspaces listed for non-authenticated users
            context["workspace_list"] = Workspace.objects.filter(
                public=True
            ).order_by("name")
            context["work_request_list"] = None

        return context


class LoginView(auth_views.LoginView):
    """Class for the login view."""

    def get_success_url(self):
        """Login succeeds: redirect to the homepage."""
        return reverse("homepage:homepage")


class LogoutView(SignonLogoutMixin, auth_views.LogoutView):
    """Class for the logout view."""


class WorkspaceListView(ListViewBase[Workspace]):
    """List workspaces."""

    model = Workspace
    template_name = "web/workspace-list.html"
    context_object_name = "workspace_list"
    ordering = "name"

    def get_queryset(self):
        """All workspaces for authenticated users or only public ones."""
        if self.request.user.is_authenticated:
            return Workspace.objects.all()
        else:
            return Workspace.objects.filter(public=True)


class DownloadPathView(
    ValidatePermissionsMixin, PaginationMixin, ListViewBase[FileInArtifact]
):
    """View to download an artifact (in .tar.gz or list 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
    ]

    def __init__(self, *args, **kwargs):
        """Initialize object."""
        self._subdirectory: Optional[str] = None
        self._artifact: Optional[Artifact] = None
        super().__init__(*args, **kwargs)

    def get(self, request, artifact_id: int, path=None):
        """Download files from the artifact in .tar.gz."""
        archive = request.GET.get("archive", None)
        original_path = 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
            )

        try:
            self._artifact = Artifact.objects.get(id=artifact_id)
        except Artifact.DoesNotExist:
            context = {"error": f"Artifact {artifact_id} does not exist"}
            return TemplateResponse(
                request, "404.html", context, status=status.HTTP_404_NOT_FOUND
            )

        if path is None:
            # List files or download .tar.gz of the whole artifact
            return self._get_directory(self._artifact, path, 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

        self._subdirectory = path + "/"
        # Try to return a .tar.gz / list of files for the directory
        directory_exists = self._artifact.fileinartifact_set.filter(
            path__startswith=self._subdirectory
        ).exists()
        if directory_exists:
            return self._get_directory(self._artifact, path, 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 "{original_path}"'
        }
        return TemplateResponse(
            request, "404.html", context, status=status.HTTP_404_NOT_FOUND
        )

    def _get_directory(
        self,
        artifact: Artifact,
        subdirectory: Optional[str],
        archive: Optional[str],
    ):
        if archive == "tar.gz":
            return self._get_directory_tar_gz(artifact, subdirectory)
        else:
            return super().get(self.request, self.args, self.kwargs)

    def _get_directory_tar_gz(
        self, artifact: Artifact, subdirectory: Optional[str]
    ):
        # 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(artifact, subdirectory), status=status.HTTP_200_OK
        )
        response["Content-Type"] = "application/octet-stream"

        directory_name = ""
        if subdirectory is not None:
            directory_name = subdirectory.removesuffix("/")
            directory_name = directory_name.replace("/", "_")
            directory_name = f"-{directory_name}"

        filename = f"artifact-{artifact.id}{directory_name}.tar.gz"
        disposition = f'attachment; filename="{filename}"'
        response["Content-Disposition"] = disposition
        response["Last-Modified"] = http_date(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()

        if (url := file_backend.get_url(file_in_artifact.file)) is not None:
            # The client can download the file from the backend
            return redirect(url)

        with open(
            file_backend.get_local_path(file_in_artifact.file), "rb"
        ) as file:
            file_size = file_in_artifact.file.size
            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
            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"

    def get_queryset(self) -> list[dict]:
        """Return list containing only files/dirs for self._subdirectory."""
        qs = (
            super()
            .get_queryset()
            .filter(artifact_id=self.kwargs["artifact_id"])
            .select_related("file")
            .order_by(Lower("path"))
        )

        if self._subdirectory is not None:
            qs = qs.filter(path__startswith=self._subdirectory)
            subdirectory_level = self._subdirectory.count("/")
        else:
            subdirectory_level = 0

        added_subdirectories = set()

        files_dirs = []

        if self._subdirectory is not None:
            # Add link to the parent directory
            files_dirs.append(
                {
                    "path": self._subdirectory + "..",
                    "name": "..",
                    "size": "-",
                    "hash": "-",
                }
            )

        for fileinartifact_obj in qs:
            if fileinartifact_obj.path.count("/") == subdirectory_level:
                # File is in the directory that is being listed (not in a
                # subdirectory) of the directory that is being listed
                fileinartifact = {
                    "path": fileinartifact_obj.path,
                    "name": Path(fileinartifact_obj.path).name,
                    "size": fileinartifact_obj.file.size,
                    "hash": fileinartifact_obj.file.hash_digest.hex(),
                }

                files_dirs.append(fileinartifact)
                continue

            # This is a file in a subdirectory of the one being listed.
            # If the directory where the file is has not been added: add it
            split_subdirectory = fileinartifact_obj.path.split("/")
            subdirectory_name = split_subdirectory[subdirectory_level]

            if subdirectory_name not in added_subdirectories:
                if self._subdirectory is None:
                    path = subdirectory_name
                else:
                    path = self._subdirectory + subdirectory_name

                fileinartifact = {
                    "path": path,
                    "name": subdirectory_name + "/",
                    "size": "-",
                    "hash": "-",
                }

                files_dirs.append(fileinartifact)
                added_subdirectories.add(subdirectory_name)

        return files_dirs

    def get_context_data(self, *args, **kwargs):
        """Return context for this view."""
        context = super().get_context_data(*args, **kwargs)
        context["artifact"] = self._artifact
        context["hash_algorithm"] = File.current_hash_algorithm
        context["subdirectory"] = self._subdirectory
        context["data_yaml"] = yaml.safe_dump(self._artifact.data)

        context["download_artifact_tar_gz_url"] = (
            reverse(
                "artifacts:detail",
                kwargs={"artifact_id": self._artifact.id},
            )
            + "?archive=tar.gz"
        )

        if self._subdirectory is not None:
            context["download_directory_tar_gz_url"] = (
                reverse(
                    "artifacts:detail-path",
                    kwargs={
                        "artifact_id": self._artifact.id,
                        "path": self._subdirectory.rstrip("/"),
                    },
                )
                + "?archive=tar.gz"
            )

        return context


class UserTokenListView(ValidatePermissionsMixin, ListViewBase[Token]):
    """List tokens for the user."""

    model = Token
    template_name = "web/user_token-list.html"
    context_object_name = "token_list"
    ordering = "name"

    permission_denied_message = "You need to be authenticated to list tokens"
    permission_classes = [IsUserAuthenticated]

    def get_queryset(self):
        """All tokens for the authenticated user."""
        return Token.objects.filter(user=self.request.user).order_by(
            'created_at'
        )


class UserTokenCreateView(
    ValidatePermissionsMixin, CreateViewBase[Token, TokenForm]
):
    """Form view for creating tokens."""

    template_name = "web/user_token-form.html"
    form_class = TokenForm
    success_url = reverse_lazy("user:token-list")

    permission_denied_message = "You need to be authenticated to create a token"
    permission_classes = [IsUserAuthenticated]

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

    def get_context_data(self, **kwargs):
        """Extend the default context: add action."""
        context = super().get_context_data(**kwargs)
        context['action'] = 'Create'
        return context


class UserTokenUpdateView(
    ValidatePermissionsMixin, UpdateViewBase[Token, TokenForm]
):
    """Form view for creating tokens."""

    model = Token
    template_name = "web/user_token-form.html"
    form_class = TokenForm
    success_url = reverse_lazy("user:token-list")

    permission_denied_message = "You need to be authenticated to edit tokens"
    permission_classes = [IsUserAuthenticated]

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

    def get_queryset(self):
        """Only include tokens for the current user."""
        return super().get_queryset().filter(user=self.request.user)

    def get_context_data(self, **kwargs):
        """Extend the default context: add action."""
        context = super().get_context_data(**kwargs)
        context['action'] = 'Edit'
        return context


class UserTokenDeleteView(
    ValidatePermissionsMixin, DeleteViewBase[Token, TokenForm]
):
    """View for deleting tokens."""

    model = Token
    template_name = "web/user_token-confirm_delete.html"
    success_url = reverse_lazy("user:token-list")

    permission_denied_message = "You need to be authenticated to delete tokens"
    permission_classes = [IsUserAuthenticated]

    def get_queryset(self):
        """Only include tokens for the current user."""
        return super().get_queryset().filter(user=self.request.user)


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