# 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 hashlib
import io
import mmap
from collections.abc import Iterable
from pathlib import Path, PurePath
from typing import Any

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 TemplateView

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.tasks.models import TaskTypes
from debusine.utils import parse_range_header
from debusine.web.forms import ArtifactForm, TokenForm
from debusine.web.views.base import (
    CreateViewBase,
    DeleteViewBase,
    ListViewBase,
    UpdateViewBase,
)
from debusine.web.views.mixins import PaginationMixin


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)
                .exclude(task_type=TaskTypes.INTERNAL)
                .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 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: Any, **kwargs: Any) -> None:
        """Initialize object."""
        self._subdirectory: str | None = None
        self._artifact: Artifact | None = 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 = subdirectory = path + "/"
        assert self._subdirectory is not None
        # Try to return a .tar.gz / list of files for the directory
        directory_exists = self._artifact.fileinartifact_set.filter(
            path__startswith=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: str | None,
        archive: str | None,
    ):
        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: str | None
    ):
        # 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()

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

    def _make_fake_file_in_artifact(
        self, artifact_id: int, path: str
    ) -> FileInArtifact:
        """
        Make a fake :class:`FileInArtifact` for a given path.

        This is not saved to the database.
        """
        return FileInArtifact(
            artifact_id=artifact_id,
            path=path,
            file=File(sha256=hashlib.sha256(b"").hexdigest(), size=0),
        )

    def get_queryset(self) -> list[FileInArtifact]:
        """Return list containing only files/dirs for self._subdirectory."""
        artifact_id = self.kwargs["artifact_id"]
        qs = (
            self.model.objects.filter(artifact_id=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(
                self._make_fake_file_in_artifact(
                    artifact_id, self._subdirectory + "../"
                )
            )

        for fileinartifact in qs:
            if fileinartifact.path.count("/") == subdirectory_level:
                # File is in the directory that is being listed (not in a
                # subdirectory) of the directory that is being listed
                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.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

                files_dirs.append(
                    self._make_fake_file_in_artifact(artifact_id, path + "/")
                )
                added_subdirectories.add(subdirectory_name)

        return files_dirs

    @staticmethod
    def _get_file_list_context(
        fileinartifact: FileInArtifact,
    ) -> dict[str, Any]:
        """Get context data for a :class:`FileInArtifact`."""
        if fileinartifact.path.endswith("/"):
            return {
                "path": fileinartifact.path.removesuffix("/"),
                "name": PurePath(fileinartifact.path).name + "/",
                "size": "-",
                "hash": "-",
            }
        else:
            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 context["object_list"]
        ]
        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

    permission_denied_message = "You need to be authenticated to create a token"
    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_context_data(self, **kwargs):
        """Extend the default context: add action."""
        context = super().get_context_data(**kwargs)
        context['action'] = 'Create'
        return context

    def form_valid(self, form):
        """Validate form and return token created page."""
        form.instance.user = self.request.user
        token = form.save()

        return TemplateResponse(
            self.request, "web/user_token-created.html", {"token": token}
        )


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[str, Any]:
        """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."""

    object: Token  # https://github.com/typeddjango/django-stubs/issues/1227
    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[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},
        )
