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

"""Template tag library to render Debusine UI elements."""

import abc
import datetime as dt
import logging
from collections.abc import Callable
from typing import Any, Never, override
from urllib.parse import urlparse

import markdown
import nh3
from django import template
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages.storage.base import Message
from django.db.models import Model
from django.template import Context, Node, NodeList, TemplateSyntaxError
from django.template.base import FilterExpression, Parser, Token, token_kwargs
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import SafeString, mark_safe
from django.utils.timesince import timesince
from more_itertools import peekable

import debusine.web.views.view_utils
from debusine.db.context import context
from debusine.db.permissioncontext import PermissionContext, PermissionUser
from debusine.server.middlewares.scopes import (
    TOKEN_LAST_SEEN_AT_UPDATE_INTERVAL,
)
from debusine.server.scopes import urlconf_scope
from debusine.web.helps import HELPS
from debusine.web.icons import Icons
from debusine.web.views.places import Place
from debusine.web.views.ui.base import UI
from debusine.web.views.widgets import Widget

logger = logging.getLogger("debusine.web")

register = template.Library()


class DebusineNodeRenderError(Exception):
    """Signal an error during rendering of a DebusineNode."""

    def __init__(
        self,
        *,
        devel_message: str,
        user_message: str | None = None,
        exception: Exception | None = None,
    ) -> None:
        """
        Store exception arguments.

        :param devel_message: message targeted at developers, shown in testing
          and logs but not to the user, to prevent leaking sensitive information
        :param user_message: message shown to the user. If not provided a
          default message is used.
        :param exception: exception (if any) that caused the error
        """
        self.devel_message = devel_message
        self.user_message = user_message or "template rendering error"
        self.exception = exception


class DebusineNode(Node, abc.ABC):
    """Django Node element with added helper functions."""

    def _render_error(
        self,
        *,
        user_message: str,
        devel_message: str,
        exception: Exception | None = None,
    ) -> SafeString:
        """
        Render a description for a rendering error.

        Depending on settings, it can either raise an exception or render a
        description suitable for template output.
        """
        if settings.DEBUG or getattr(settings, "TEST_MODE", False):
            if exception is not None:
                exception.add_note(devel_message)
                raise exception
            else:
                raise ValueError(devel_message)

        # When running in production, avoid leaking possibly sensitive error
        # information while providing enough information for a potential bug
        # report to locate the stack trace in the logs
        logger.warning(
            "%s rendering error: %s",
            self.__class__.__name__,
            devel_message,
            exc_info=exception,
        )
        return format_html(
            "<span data-role='debusine-template-error'"
            " class='bg-danger text-white'>{ts} UTC: {message}</span>",
            ts=dt.datetime.now(dt.UTC).isoformat(),
            message=user_message,
        )

    def resolve_filter(
        self,
        context: Context,
        value: FilterExpression,
        value_name: str,
        fail_if_none: bool = True,
    ) -> Any:
        """
        Resolve a filter expression.

        If ``fail_if_none`` is False, returns None if the filter expression
        failed. Raises DebusineNodeRenderError on other kind of failures.
        """
        try:
            # ignore_failures seems counterintuitive to me.
            # If False, then django will *handle* failures, generating a value
            # using `string_if_invalid` configured in the template `Engine`.
            # If True, then django will *not* handle failures, and use None for
            # an undefined variable, which is something that we can look for.
            res = value.resolve(context, ignore_failures=True)
        except Exception as e:
            raise DebusineNodeRenderError(
                user_message="template argument malformed",
                devel_message=f"Invalid {value_name} argument: {str(value)!r}",
                exception=e,
            )

        if res is None and fail_if_none:
            raise DebusineNodeRenderError(
                user_message="template argument lookup failed",
                devel_message=f"{value_name} argument {str(value)!r}"
                " failed to resolve",
            )

        return res

    @abc.abstractmethod
    def render_checked(self, context: Context) -> str | SafeString:
        """
        Render the template node.

        Exceptions raised in this method are caught and rendered and logged as
        appropriate.
        """

    @override
    def render(self, context: Context) -> str | SafeString:
        try:
            return self.render_checked(context)
        except DebusineNodeRenderError as de:
            return self._render_error(
                devel_message=de.devel_message,
                user_message=de.user_message,
                exception=de.exception,
            )
        except Exception as e:
            return self._render_error(
                user_message="template rendering error",
                devel_message="unhandled exception in render_checked",
                exception=e,
            )


class WidgetNode(DebusineNode):
    """Template node implementing the {% widget %} tag."""

    def __init__(
        self,
        value: FilterExpression,
        extra_context: dict[str, FilterExpression],
    ) -> None:
        """Store node arguments."""
        self.value = value
        self.extra_context = extra_context

    def resolve_extra_context(self, context: Context) -> dict[str, Any]:
        """Resolve extra_context based on the template context."""
        return {
            name: self.resolve_filter(context, value, name)
            for name, value in self.extra_context.items()
        }

    @override
    def render_checked(self, context: Context) -> str | SafeString:
        # Validate the value as a widget
        match value := self.resolve_filter(context, self.value, "widget"):
            case str():
                if context.autoescape:
                    return conditional_escape(value)
                else:
                    return value
            case Widget():
                try:
                    return value.render(
                        context, **self.resolve_extra_context(context)
                    )
                except Exception as e:
                    raise DebusineNodeRenderError(
                        user_message=(
                            f"{value.__class__.__name__} failed to render"
                        ),
                        devel_message=(
                            f"Widget {self.value.token} ({value!r})"
                            " failed to render"
                        ),
                        exception=e,
                    )
            case Callable():
                if getattr(value, "debusine_widget", False) is False:
                    raise DebusineNodeRenderError(
                        user_message=(
                            f"{self.value.token} is not tagged"
                            " as a Debusine widget"
                        ),
                        devel_message=(
                            f"Widget {self.value.token} ({value!r})"
                            " is not tagged as a Debusine widget"
                        ),
                    )
                try:
                    args: list[Context] = []
                    kwargs = self.resolve_extra_context(context)
                    if getattr(value, "debusine_takes_context", False):
                        args.append(context)
                    result = value(*args, **kwargs)
                    assert isinstance(result, str)
                    return result
                except Exception as e:
                    raise DebusineNodeRenderError(
                        user_message=(f"{value.__name__} failed to render"),
                        devel_message=(
                            f"Widget {self.value.token} ({value!r})"
                            " failed to render"
                        ),
                        exception=e,
                    )

            case _:
                raise DebusineNodeRenderError(
                    user_message="invalid widget type",
                    devel_message=(
                        f"widget {self.value.token} {value!r} has invalid type"
                    ),
                )


@register.tag
def widget(parser: Parser, token: Token) -> WidgetNode:
    """Parser for the {% widget %} tag."""
    bits = token.split_contents()
    if len(bits) == 1:
        raise TemplateSyntaxError(f"{bits[0]} requires at least one argument")
    value = bits[1]
    remaining_bits = bits[2:]
    extra_context = token_kwargs(remaining_bits, parser)
    if remaining_bits:
        raise TemplateSyntaxError(
            f"{bits[0]} received an invalid token: {remaining_bits[0]!r}"
        )
    return WidgetNode(parser.compile_filter(value), extra_context=extra_context)


class WithScopeNode(Node):
    """Template node implementing the {% withscope %} tag."""

    def __init__(self, value: FilterExpression, nodelist: NodeList) -> None:
        """Store node arguments."""
        self.value = value
        self.nodelist = nodelist

    @override
    def render(self, context: Context) -> SafeString:
        from debusine.db.context import context as appcontext
        from debusine.db.models import Scope

        # Resolve the argument token to a value
        value: str | Scope = self.value.resolve(context)

        # Resolve a scope name to a Scope object
        match value:
            case str():
                try:
                    value = Scope.objects.get(name=value)
                except Scope.DoesNotExist:
                    return self.nodelist.render(context)
            case Scope():
                pass
            case _:
                return self.nodelist.render(context)

        pc = appcontext.pc
        with urlconf_scope(value.name), appcontext.local():
            # Render the contained template using a different current scope
            appcontext.reset()
            # Note: this triggers an extra database query, since set_user needs
            # to go and load what roles the user has on the scope.
            # TODO: if this is a problem in big querysets, the same can be done
            #       with Scope as with Workspace.objects.with_role_annotations
            appcontext.set_permission_context_scoped(value, pc)
            # Workspace is not preserved, since the scope changed
            # worker_token is not preserved since it makes no sense for
            #              templates
            # TODO: permission_checks_disabled is not currently preserved as
            #       there is no use case to justify it
            return self.nodelist.render(context)


@register.tag
def withscope(parser: Parser, token: Token) -> WithScopeNode:
    """Parser for the {% withscope %} tag."""
    bits = token.split_contents()
    if len(bits) == 2:
        _, value = bits
    else:
        raise TemplateSyntaxError("withscope requires exactly one argument")

    nodelist = parser.parse(("endwithscope",))
    parser.delete_first_token()

    return WithScopeNode(parser.compile_filter(value), nodelist)


@register.filter
def has_perm(resource: Model, name: str) -> bool:
    """Check a permission predicate."""
    predicate = getattr(resource, name)
    result = predicate(context.pc)
    assert isinstance(result, bool)
    return result


@register.filter
def roles(resource: Model, user: PermissionUser | None = None) -> list[str]:
    """Return the list of roles the user has on the resource."""
    if (get_roles := getattr(resource, "get_roles", None)) is None:
        return []
    pc: PermissionContext
    match user:
        case None:
            pc = context.pc
        case AnonymousUser():
            pc = PermissionContext.for_ui_anonymous()
        case _:
            pc = PermissionContext.for_ui_user(user)
    return sorted(get_roles(pc))


@register.filter
def as_yaml(value: Any, flags: str = "") -> SafeString:
    """Return syntax-highlighted HTML for value serialized as YAML."""
    sort_keys = "unsorted" not in flags.split(",")
    return debusine.web.views.view_utils.highlight_as_yaml(
        value, sort_keys=sort_keys
    )


@register.simple_tag
def icon(name: str) -> str:
    """Lookup an icon by name."""
    return "bi-" + getattr(Icons, name.upper(), "square-fill")


@register.filter(name="sorted")
def sorted_(value: Any) -> Any:
    """Sort a sequence of values."""
    return sorted(value)


@register.filter
def is_list_like(value: Any) -> bool:
    """Check if a value is list-like."""
    return isinstance(value, (list, tuple))


@register.filter
def is_dict_like(value: Any) -> bool:
    """Check if a value is dict-like."""
    return isinstance(value, dict)


# https://code.djangoproject.com/ticket/12486
@register.filter
def lookup[KT, VT](d: dict[KT, VT], key: KT) -> VT:
    """Get a value from a dictionary."""
    return d[key]


@register.simple_tag(name="help")
def _help(name: str) -> str:
    """Return HTML with a "?" icon and a popover with the html."""
    return render_to_string("web/_help.html", HELPS[name]._asdict())


class UINode(DebusineNode):
    """Template node implementing the {% ui %} tag."""

    def __init__(self, objvar: FilterExpression, asvar: str) -> None:
        """Store node arguments."""
        self.objvar = objvar
        self.asvar = asvar

    @override
    def render_checked(self, context: Context) -> SafeString:
        """Look up the helper and store it in the context."""
        context[self.asvar] = None
        match instance := self.resolve_filter(context, self.objvar, "resource"):
            case Model():
                try:
                    context[self.asvar] = UI.for_instance(
                        context["request"], instance
                    )
                except Exception as e:
                    raise DebusineNodeRenderError(
                        user_message="template argument lookup failed",
                        devel_message=f"ui helper lookup for {instance!r}"
                        " failed",
                        exception=e,
                    )

            case _:
                raise DebusineNodeRenderError(
                    user_message="template argument lookup failed",
                    devel_message=f"ui model instance {self.objvar!r}"
                    f" {instance!r} has invalid type",
                )
        return SafeString()


@register.tag
def ui(parser: Parser, token: Token) -> UINode:
    """
    Lookup the UI helper for a model object.

    Usage::

        {% ui file as fileui %}

        ...use fileui at will...
    """
    bits = token.split_contents()
    asvar = None
    if len(bits) == 4 and bits[-2] == "as":
        objvar = bits[1]
        asvar = bits[3]
    else:
        raise TemplateSyntaxError(
            "'ui' statement syntax is {% ui [object] as [variable] %}"
        )
    return UINode(parser.compile_filter(objvar), asvar)


class PlaceNode(DebusineNode):
    """Template node implementing the {% place %} tag."""

    def __init__(
        self,
        objvar: FilterExpression,
        place_type: str | None,
        widget_type: str,
        kwargs: list[tuple[str, FilterExpression]],
    ) -> None:
        """Store node arguments."""
        self.objvar = objvar
        self.place_type = place_type
        self.widget_type = widget_type
        self.kwargs = kwargs

    def _resolve_place(self, context: Context) -> Place:
        """Resolve self.objvar into a Place."""
        place_method_name = (
            "place" if self.place_type is None else f"place_{self.place_type}"
        )

        instance = self.resolve_filter(context, self.objvar, "object")

        if isinstance(instance, Model):
            try:
                instance = UI.for_instance(context["request"], instance)
            except Exception as e:
                raise DebusineNodeRenderError(
                    user_message="ui lookup failed",
                    devel_message=f"ui helper lookup for {self.objvar!r}"
                    " failed",
                    exception=e,
                )

        if isinstance(instance, UI):
            try:
                instance = getattr(instance, place_method_name)
            except Exception as e:
                raise DebusineNodeRenderError(
                    user_message="place lookup failed",
                    devel_message=f"place method {place_method_name!r}"
                    f" for {instance!r} failed",
                    exception=e,
                )

        if isinstance(instance, Place):
            return instance

        raise DebusineNodeRenderError(
            user_message="invalid object type",
            devel_message=f"object {self.objvar!r} resolved to unsupported"
            f" object type {instance.__class__.__name__} ({instance!r})",
        )

    def _resolve_kwargs(self, context: Context) -> dict[str, Any]:
        """Resolve self.kwargs into a dict of keyword arguments."""
        kwargs: dict[str, Any] = {}
        for key, value_lookup in self.kwargs:
            kwargs[key] = self.resolve_filter(
                context, value_lookup, "kwarg value"
            )
        return kwargs

    @override
    def render_checked(self, context: Context) -> str:
        place = self._resolve_place(context)

        # Resolve render method
        renderer: Callable[..., str] | None = getattr(
            place, self.widget_type, None
        )
        if renderer is None:
            raise DebusineNodeRenderError(
                user_message="invalid place render type",
                devel_message=f"place {place!r} does not have"
                f" an {self.widget_type} method",
            )

        # Resolve kwargs
        kwargs = self._resolve_kwargs(context)

        # Call the render method
        try:
            return renderer(**kwargs)
        except Exception as e:
            raise DebusineNodeRenderError(
                user_message="place rendering failed",
                devel_message=f"place {place!r} {self.widget_type}"
                " method failed",
                exception=e,
            )


def _place_template_syntax_error(message: str) -> Never:
    """Raise TemplateSyntaxError for ``{% place %}`` tags."""
    raise TemplateSyntaxError(
        message + ". Usage: {% place {object} [place_type] as_{widget_type}"
        " [key=value, ...] %}"
    )


@register.tag
def place(parser: Parser, token: Token) -> PlaceNode:
    """
    Render Place elements.

    Usage::

        {% place {Model|UI|Place} [type] as_[widget] [key=value, ...] %}

    The first argument is used to obtain a Place instance:

    * if it's a ``Place`` instance, it is used
    * if it's a UI helper instance, ``place`` or ``place_{type}`` is invoked
    * if it's a ``Model`` instance, its UI helper is instantiated and used to
      get a ``Place`` as in the previous point

    Then the ``as_[widget]`` method is called on the ``Place`` instance to
    render it. Any trailing ``key=value`` assignments are passed to the
    ``as_[widget]`` method.
    """
    bits = token.split_contents()

    args = peekable(bits)
    next(args)  # discard "place"

    # Get resource/ui helper/place
    if not args:
        _place_template_syntax_error("object not provided")
    obj: str = next(args)

    # Get optional place type
    place_type: str | None = None
    if not args:
        _place_template_syntax_error("as_[widget] not provided")
    if not args.peek().startswith("as_"):
        place_type = next(args)

    # Get widget type
    if not args:
        _place_template_syntax_error("as_[widget] not provided")
    widget_type: str = next(args)
    if not widget_type.startswith("as_"):
        _place_template_syntax_error("widget type does not begin with `as_`")

    kwargs: list[tuple[str, FilterExpression]] = []
    for kwarg in args:
        if "=" not in kwarg:
            _place_template_syntax_error("missing = in key=value assignment")
        key, arg = kwarg.split("=", 1)
        if not key:
            _place_template_syntax_error("empty key in key=value assignment")
        kwargs.append((key, parser.compile_filter(arg)))

    return PlaceNode(
        parser.compile_filter(obj), place_type, widget_type, kwargs
    )


@register.filter
def message_toast_color_class(message: Message) -> str:
    """Get the bootstrap color class to use for showing Django messages."""
    match message.level:
        case messages.SUCCESS:
            return "success"
        case messages.WARNING:
            return "warning"
        case messages.ERROR:
            return "danger"
        case _:
            return "light"


@register.filter
def message_toast_header_icon(message: Message) -> str:
    """Get toast header classes to use for showing Django messages."""
    match message.level:
        case messages.WARNING | messages.ERROR:
            return "bi bi-exclamation-triangle"
        case _:
            return "bi bi-info-circle"


@register.filter
def worker_last_seen(value: dt.datetime | None) -> str:
    """Return human-readable text for a worker's last-seen timestamp."""
    if value is None:
        return "Never"

    if timezone.now() - value <= TOKEN_LAST_SEEN_AT_UPDATE_INTERVAL:
        minutes = int(TOKEN_LAST_SEEN_AT_UPDATE_INTERVAL.total_seconds() / 60)
        return f"Less than {minutes} minutes ago"

    return f"{timesince(value)} ago"


@register.filter
def markdown_as_html(markdown_text: str) -> SafeString:
    """Return HTML rendered from markdown_text."""

    def nh3_no_relative_urls_filter(
        tag: str, attribute: str, value: str
    ) -> str | None:
        # remove this function and use `nh3.clean()` kwarg `url_relative="deny"`
        # when using nh3 version >= 0.3.6
        if (tag == "a" and attribute == "href") or (
            tag == "img" and attribute == "src"
        ):
            parsed = urlparse(value)
            if not parsed.scheme and not value.startswith("//"):
                return None

        return value

    return mark_safe(
        nh3.clean(
            markdown.markdown(markdown_text),
            # tags list is the same as
            # https://github.com/yourcelf/bleach-allowlist/blob/main/bleach_allowlist/bleach_allowlist.py#L53
            tags={
                "h1",
                "h2",
                "h3",
                "h4",
                "h5",
                "h6",
                "b",
                "i",
                "strong",
                "em",
                "tt",
                "p",
                "br",
                "span",
                "div",
                "blockquote",
                "code",
                "pre",
                "hr",
                "ul",
                "ol",
                "li",
                "dd",
                "dt",
                "img",
                "a",
                "sub",
                "sup",
            },
            # Same attributes as
            # https://github.com/yourcelf/bleach-allowlist/blob/main/bleach_allowlist/bleach_allowlist.py#L64
            attributes={
                "*": {"id"},
                "img": {"src", "alt", "title"},
                "a": {"href", "alt", "title"},
            },
            link_rel="noopener noreferrer nofollow",
            url_schemes={"http", "https", "mailto"},
            attribute_filter=nh3_no_relative_urls_filter,
        )
    )
