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

"""Pagination support widgets."""

from collections.abc import Sequence
from functools import cached_property
from typing import (
    Any,
    Generic,
    TYPE_CHECKING,
    TypeAlias,
    TypeVar,
    assert_never,
    cast,
)

from django.core.paginator import Page, Paginator
from django.db.models import F, Model, QuerySet
from django.db.models.expressions import OrderBy
from django.http import HttpRequest
from django.template.context import BaseContext
from django.utils.html import format_html
from django.utils.safestring import SafeString, mark_safe

from debusine.web.views.base import Widget

if TYPE_CHECKING:
    PaginatorBase = Paginator
    PageBase = Page
else:
    # Django's Paginator don't support generic types at run-time yet.
    class _PaginatorBase:
        def __class_getitem__(*args):
            return Paginator

    class _PageBase:
        def __class_getitem__(*args):
            return Page

    PaginatorBase = _PaginatorBase
    PageBase = _PageBase


M = TypeVar("M", bound=Model)

OrderBys: TypeAlias = str | F | OrderBy | Sequence[str | F | OrderBy]


def to_order_by(val: str | F | OrderBy) -> OrderBy:
    """Convert the argument to an F-expression if needed."""
    match val:
        case str():
            if val.startswith("-"):
                return F(val[1:]).desc()
            else:
                return F(val).asc()
        case F():
            return val.asc()
        case OrderBy():
            return val
        case _ as unreachable:
            assert_never(unreachable)


def order_by_tuple(arg: OrderBys) -> tuple[OrderBy, ...]:
    """Normalise an argument to a tuple of OrderBy expressions."""
    match arg:
        case str() | F() | OrderBy():
            return (to_order_by(arg),)
        case _:
            return tuple(to_order_by(x) for x in arg)


class Ordering:
    """OrderBy expression for ascending and descending ordering."""

    #: QuerySet.order_by arguments for ascending sort
    asc: tuple[OrderBy, ...]
    #: QuerySet.order_by arguments for descending sort
    desc: tuple[OrderBy, ...]

    def __init__(
        self,
        asc: OrderBys,
        desc: OrderBys | None = None,
    ) -> None:
        """Convert arguments to OrderBy expressions."""
        self.asc = order_by_tuple(asc)
        if desc is None:
            self.desc = tuple(
                cast(OrderBy, x.copy().reverse_ordering()) for x in self.asc
            )
        else:
            self.desc = order_by_tuple(desc)


class Column(Widget):
    """A sortable column."""

    #: Column name
    name: str
    #: Column title
    title: str
    #: Ordering
    ordering: Ordering

    def __init__(
        self,
        pagination: "Pagination[Any]",
        name: str,
        title: str,
        ordering: Ordering,
        current: bool,
    ) -> None:
        """Store arguments in the Column object for rendering."""
        self.pagination = pagination
        self.name = name
        self.title = title
        self.ordering = ordering
        self.current = current

    @cached_property
    def query_asc(self) -> str:
        """Return the query string for sorting by this column (ascending)."""
        query = self.pagination.request.GET.copy()
        query[f"{self.pagination.prefix}order"] = self.name
        query[f"{self.pagination.prefix}asc"] = "1"
        return query.urlencode()

    @cached_property
    def query_desc(self) -> str:
        """Return the query string for sorting by this column (descending)."""
        query = self.pagination.request.GET.copy()
        query[f"{self.pagination.prefix}order"] = self.name
        query[f"{self.pagination.prefix}asc"] = "0"
        return query.urlencode()

    def render(self, context: BaseContext) -> str:  # noqa: U100
        """Render the shortcut as a form."""
        if self.current:
            if self.pagination.asc:
                return format_html(
                    "{title} "
                    "<span class='order-active'>&#9650;</span>"
                    "<a href='?{query}' class='order-inactive'>"
                    "<span class='arrow'>&#9660;</span></a>",
                    title=self.title,
                    query=self.query_desc,
                )
            else:
                return format_html(
                    "{title} "
                    "<a href='?{query}' class='order-inactive'>"
                    "<span class='arrow'>&#9650;</span></a>"
                    "<span class='order-active'>&#9660;</span>",
                    title=self.title,
                    query=self.query_asc,
                )
        else:
            return format_html(
                "{title} "
                "<a href='?{query_asc}' class='order-inactive'>&#9650;</a>"
                "<a href='?{query_desc}' class='order-inactive'>&#9660;</a>",
                title=self.title,
                query_asc=self.query_asc,
                query_desc=self.query_desc,
            )


class PageNavigation(Widget):
    """Render the page navigation bar."""

    def __init__(self, pagination: "Pagination[Any]") -> None:
        """Build the PageNavigation widget."""
        self.pagination = pagination

    def render(self, context: BaseContext) -> str:  # noqa: U100
        """Render the widget."""
        paginator = self.pagination.paginator
        page_obj = self.pagination.page_obj
        elided_page_range = paginator.get_elided_page_range(page_obj.number)
        chunks: list[SafeString] = []
        if paginator.num_pages > 1:
            chunks.append(SafeString("<nav aria-label='pagination'>"))
            chunks.append(SafeString("<ul class='pagination'>"))
            for page_number in elided_page_range:
                if page_number == page_obj.number:
                    chunks.append(
                        format_html(
                            "<li class='page-item active' aria-current='page'>"
                            "<span class='page-link'>{page_number}</span>"
                            "</li>",
                            page_number=str(page_number),
                        )
                    )
                elif (
                    page_number  # type: ignore[comparison-overlap]
                    is page_obj.paginator.ELLIPSIS
                ):
                    chunks.append(
                        format_html(
                            "<li class='page-item'>"
                            "<span class='page-link'>{page_number}</span>"
                            "</li>",
                            page_number=str(page_number),
                        )
                    )
                else:
                    query = self.pagination.request.GET.copy()
                    query[f"{self.pagination.prefix}page"] = str(page_number)
                    chunks.append(
                        format_html(
                            "<li class='page-item'>"
                            "<a class='page-link' href='?{query}'>"
                            "{page_number}</a>"
                            "</li>",
                            query=query.urlencode(),
                            page_number=page_number,
                        )
                    )
            chunks.append(SafeString("</ul>"))
            chunks.append(SafeString("</nav>"))
        return mark_safe(SafeString("").join(chunks))


class Pagination(Generic[M]):
    """Pagination handling via widgets."""

    columns: dict[str, Column]
    default_ordering: tuple[OrderBy, ...] | None

    def __init__(
        self,
        request: HttpRequest,
        queryset: QuerySet[M, M],
        *,
        prefix: str = "",
        default_ordering: OrderBys | None = None,
        page_size: int = 10,
    ):
        """
        Build the Pagination object.

        :param request: the current request object
        :param queryset: the queryset to paginate
        :param prefix: the query string prefix for pagination arguments (set
                       this if you paginate multiple querysets in the same
                       page)
        :param default_ordering: ordering to apply when no column is selected
                                 for sorting (None for no ordering)
        :param page_size: number of items to show per page
        """
        self.request = request
        self.queryset = queryset
        self.prefix = prefix
        if default_ordering is None:
            self.default_ordering = None
        else:
            self.default_ordering = order_by_tuple(default_ordering)
        self.page_size = page_size
        self.columns = {}

    def add_column(self, name: str, title: str, ordering: Ordering) -> Column:
        """Add a column definition."""
        self.columns[name] = col = Column(
            self,
            name=name,
            title=title,
            ordering=ordering,
            current=self.current_column_name == name,
        )
        return col

    @cached_property
    def current_column_name(self) -> str | None:
        """Return the name of the column selected for sorting (if any)."""
        return self.request.GET.get(f"{self.prefix}order")

    @cached_property
    def current_column(self) -> Column | None:
        """Return the column selected for sorting (if any)."""
        if self.current_column_name is None:
            return None
        return self.columns.get(self.current_column_name)

    @cached_property
    def asc(self) -> bool:
        """Check if the requested order is ascending."""
        return self.request.GET.get(f"{self.prefix}asc", "1") != "0"

    @cached_property
    def ordered_queryset(
        self,
    ) -> QuerySet[M, M]:
        """Return the queryset sorted as the user requested."""
        if self.current_column is None:
            if self.default_ordering is None:
                return self.queryset
            else:
                return self.queryset.order_by(*self.default_ordering)

        ordering = self.current_column.ordering

        if self.asc:
            return self.queryset.order_by(*ordering.asc)
        else:
            return self.queryset.order_by(*ordering.desc)

    @cached_property
    def paginator(self) -> PaginatorBase[M]:
        """Return the Django Paginator object."""
        return Paginator(self.ordered_queryset, self.page_size)

    @cached_property
    def page_obj(self) -> PageBase[M]:
        """Return the Django paginator.Page object."""
        return self.paginator.get_page(
            self.request.GET.get(f"{self.prefix}page")
        )

    @cached_property
    def page_navigation(self) -> PageNavigation:
        """Return the PageNavigation widget."""
        return PageNavigation(self)
