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

"""Base infrastructure for web views."""

import abc
import logging
from collections.abc import Callable
from typing import Any, NoReturn, TYPE_CHECKING, TypeAlias, override

from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpRequest
from django.http.response import HttpResponseBase
from django.template.context import Context
from django.views.generic import (
    CreateView,
    DeleteView,
    DetailView,
    ListView,
    UpdateView,
)
from django.views.generic.base import ContextMixin, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormMixin, FormView
from django.views.generic.list import MultipleObjectMixin

from debusine.artifacts.models import CollectionCategory
from debusine.db.context import ContextConsistencyError, context
from debusine.db.models import Scope, Workspace
from debusine.db.models.permissions import format_permission_check_error
from debusine.db.permissioncontext import PermissionContext
from debusine.utils import debusine_version
from debusine.web.views.places import IndexPlace, Place

if TYPE_CHECKING:
    CreateViewBase: TypeAlias = CreateView
    DeleteViewBase: TypeAlias = DeleteView
    DetailViewBase: TypeAlias = DetailView
    ListViewBase: TypeAlias = ListView
    UpdateViewBase: TypeAlias = UpdateView
    FormMixinBase: TypeAlias = FormMixin
    FormViewBase: TypeAlias = FormView
    SingleObjectMixinBase: TypeAlias = SingleObjectMixin
    MultipleObjectMixinBase: TypeAlias = MultipleObjectMixin
else:
    # Django's generic views don't support generic types at run-time yet.
    from debusine.utils.typing_utils import patch_runtime_generic_support

    CreateViewBase = patch_runtime_generic_support(CreateView)
    DeleteViewBase = patch_runtime_generic_support(DeleteView)
    DetailViewBase = patch_runtime_generic_support(DetailView)
    ListViewBase = patch_runtime_generic_support(ListView)
    UpdateViewBase = patch_runtime_generic_support(UpdateView)
    FormMixinBase = patch_runtime_generic_support(FormMixin)
    FormViewBase = patch_runtime_generic_support(FormView)
    SingleObjectMixinBase = patch_runtime_generic_support(SingleObjectMixin)
    MultipleObjectMixinBase = patch_runtime_generic_support(MultipleObjectMixin)


class Widget(abc.ABC):
    """Base class for template-renderable elements."""

    @abc.abstractmethod
    def render(self, context: Context) -> str:
        """Render the element."""


class TemplateWidget(Widget):
    """Widget implementation that uses a template."""

    template_name: str

    def get_template_name(self) -> str:
        """Return the name of the template to use."""
        return self.template_name

    def get_context_data(self) -> dict[str, Any]:
        """Return the template context."""
        return {"widget": self}

    @override
    def render(self, context: Context) -> str:
        assert context.template is not None
        template = context.template.engine.get_template(
            self.get_template_name()
        )
        with context.update(self.get_context_data()):
            return template.render(context)


class BaseUIView(ContextMixin, View):
    """Base class for Debusine web views."""

    base_template = "web/_base_scoped.html"
    title = ""

    allow_work_request_tokens: bool = False

    # If a member is defined for these methods
    http_init_view_method_names = [
        "get",
        "post",
        "put",
        "patch",
        "delete",
        "head",
    ]

    @override
    def dispatch(
        self, request: HttpRequest, *args: Any, **kwargs: Any
    ) -> HttpResponseBase:
        """Call self.init_view when appropriate."""
        try:
            pc = context.pc
            work_request_token = pc.work_request_token
            non_permission_bearing_token = pc.is_non_permission_bearing_token
        except ContextConsistencyError:
            work_request_token = None
            non_permission_bearing_token = False

        if work_request_token and not self.allow_work_request_tokens:
            raise PermissionDenied(
                "work request tokens cannot be used with web views"
            )
        if non_permission_bearing_token:
            raise PermissionDenied(
                "special purpose tokens cannot be used with web views"
            )

        assert request.method is not None
        if (
            method := request.method.lower()
        ) in self.http_init_view_method_names:
            if hasattr(self, method):
                self.init_view()
        return super().dispatch(request, *args, **kwargs)

    def init_view(self) -> None:
        """
        Initialize the view.

        Call this method to lookup common objects, or perform permission
        checks.
        """

    def get_place(self) -> Place:
        """Get a Place object for this page."""
        return IndexPlace(title=self.title, url=self.request.get_full_path())

    def get_base_template(self) -> str:
        """Return the name of the base template to use with this view."""
        return self.base_template

    @override
    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
        """
        Add base template information to the template context.

        Added elements:

        * base_template: name of the base template to load
        * title: string to use as default page title and header
        """
        ctx = super().get_context_data(**kwargs)
        ctx["theme"] = self.request.COOKIES.get("theme", None)
        ctx["base_template"] = self.get_base_template()
        ctx["place"] = self.get_place()
        ctx["scopes"] = Scope.objects.can_display(context.pc).order_by("name")
        if context.workspace:
            ctx["other_workspaces"] = list(
                Workspace.objects.can_display(context.pc)
                .exclude(pk=context.workspace.pk)
                .order_by("name")
            )
            ctx["workspace_collections"] = (
                context.workspace.collections.exclude(
                    category=CollectionCategory.WORKFLOW_INTERNAL
                )
                .can_display(context.pc)
                .order_by("name", "category")
            )
            ctx["workflow_templates"] = list(
                context.workspace.workflowtemplate_set.can_display(
                    context.pc
                ).order_by("name")
            )
        ctx["debusine_version"] = debusine_version()
        return ctx

    def raise_workspace_not_found(self, workspace: str | Workspace) -> NoReturn:
        """
        Raise Http404 for a workspace not found.

        This is abstracted to provide consistent error messages also in case of
        permission denied, to prevent leaking the presence of workspaces in
        unaccessible scopes
        """
        if isinstance(workspace, Workspace):
            workspace = workspace.name
        raise Http404(
            f"Workspace {workspace} not found in scope {context.scope}, or "
            f"you are not authorized to see it"
        )

    def set_current_workspace(
        self, workspace: str | Workspace, *, check_visibility: bool = True
    ) -> None:
        """Set the current workspace in context."""
        if isinstance(workspace, str):
            try:
                workspace = Workspace.objects.get_for_context(name=workspace)
            except Workspace.DoesNotExist:
                self.raise_workspace_not_found(workspace)
        try:
            workspace.set_current(check_visibility=check_visibility)
        except ContextConsistencyError:
            # Turn exception in a 404 response.
            # 404 is used instead of 403 as an attempt to prevent leaking which
            # private workspace exists that the user cannot see
            logging.debug("permission denied on %s reported as 404", workspace)
            self.raise_workspace_not_found(workspace)

    def enforce(self, predicate: Callable[[PermissionContext], bool]) -> None:
        """Enforce a permission predicate."""
        if predicate(context.pc):
            return

        raise PermissionDenied(
            format_permission_check_error(predicate, context.pc),
        )


class WorkspaceView(BaseUIView, View):
    """Common structure for views under the /<workspace>/ URL namespace."""

    @override
    def init_view(self) -> None:
        """Set the current workspace."""
        super().init_view()
        self.set_current_workspace(self.kwargs["wname"])
        assert context.workspace is not None
        self.enforce(context.workspace.can_display)
