import collections.abc
import inspect
import warnings
from math import ceil

from asgiref.sync import iscoroutinefunction, sync_to_async

from django.utils.deprecation import RemovedInDjango70Warning
from django.utils.functional import cached_property
from django.utils.inspect import method_has_no_args
from django.utils.translation import gettext_lazy as _


class UnorderedObjectListWarning(RuntimeWarning):
    pass


class InvalidPage(Exception):
    pass


class PageNotAnInteger(InvalidPage):
    pass


class EmptyPage(InvalidPage):
    pass


class BasePaginator:
    # Translators: String used to replace omitted page numbers in elided page
    # range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10].
    ELLIPSIS = _("…")
    default_error_messages = {
        "invalid_page": _("That page number is not an integer"),
        "min_page": _("That page number is less than 1"),
        "no_results": _("That page contains no results"),
    }

    def __init__(
        self,
        object_list,
        per_page,
        orphans=0,
        allow_empty_first_page=True,
        error_messages=None,
    ):
        self.object_list = object_list
        self._check_object_list_is_ordered()
        self.per_page = int(per_page)
        self.orphans = int(orphans)
        self.allow_empty_first_page = allow_empty_first_page
        self.error_messages = (
            self.default_error_messages
            if error_messages is None
            else self.default_error_messages | error_messages
        )
        if self.per_page <= self.orphans:
            # RemovedInDjango70Warning: When the deprecation ends, replace
            # with:
            # raise ValueError(
            #     "The orphans argument cannot be larger than or equal to the "
            #     "per_page argument."
            # )
            msg = (
                "Support for the orphans argument being larger than or equal to the "
                "per_page argument is deprecated. This will raise a ValueError in "
                "Django 7.0."
            )
            warnings.warn(msg, category=RemovedInDjango70Warning, stacklevel=2)

    def _check_object_list_is_ordered(self):
        """
        Warn if self.object_list is unordered (typically a QuerySet).
        """
        ordered = getattr(self.object_list, "ordered", None)
        if ordered is not None and not ordered:
            obj_list_repr = (
                "{} {}".format(
                    self.object_list.model, self.object_list.__class__.__name__
                )
                if hasattr(self.object_list, "model")
                else "{!r}".format(self.object_list)
            )
            warnings.warn(
                "Pagination may yield inconsistent results with an unordered "
                "object_list: {}.".format(obj_list_repr),
                UnorderedObjectListWarning,
                stacklevel=3,
            )

    def _get_elided_page_range(
        self, number, num_pages, page_range, on_each_side=3, on_ends=2
    ):
        """
        Return a 1-based range of pages with some values elided.

        If the page range is larger than a given size, the whole range is not
        provided and a compact form is returned instead, e.g. for a paginator
        with 50 pages, if page 43 were the current page, the output, with the
        default arguments, would be:

            1, 2, …, 40, 41, 42, 43, 44, 45, 46, …, 49, 50.
        """
        if num_pages <= (on_each_side + on_ends) * 2:
            for page in page_range:
                yield page
            return

        if number > (1 + on_each_side + on_ends) + 1:
            for page in range(1, on_ends + 1):
                yield page
            yield self.ELLIPSIS
            for page in range(number - on_each_side, number + 1):
                yield page
        else:
            for page in range(1, number + 1):
                yield page

        if number < (num_pages - on_each_side - on_ends) - 1:
            for page in range(number + 1, number + on_each_side + 1):
                yield page
            yield self.ELLIPSIS
            for page in range(num_pages - on_ends + 1, num_pages + 1):
                yield page
        else:
            for page in range(number + 1, num_pages + 1):
                yield page

    def _get_page(self, *args, **kwargs):
        """
        Return an instance of a single page.

        This hook can be used by subclasses to use an alternative to the
        standard :cls:`Page` object.
        """
        return Page(*args, **kwargs)

    def _validate_number(self, number, num_pages):
        """Validate the given 1-based page number."""
        try:
            if isinstance(number, float) and not number.is_integer():
                raise ValueError
            number = int(number)
        except (TypeError, ValueError):
            raise PageNotAnInteger(self.error_messages["invalid_page"])
        if number < 1:
            raise EmptyPage(self.error_messages["min_page"])
        if number > num_pages:
            raise EmptyPage(self.error_messages["no_results"])
        return number


class Paginator(BasePaginator):
    def __iter__(self):
        for page_number in self.page_range:
            yield self.page(page_number)

    def validate_number(self, number):
        return self._validate_number(number, self.num_pages)

    def get_page(self, number):
        """
        Return a valid page, even if the page argument isn't a number or isn't
        in range.
        """
        try:
            number = self.validate_number(number)
        except PageNotAnInteger:
            number = 1
        except EmptyPage:
            number = self.num_pages
        return self.page(number)

    def page(self, number):
        """Return a Page object for the given 1-based page number."""
        number = self.validate_number(number)
        bottom = (number - 1) * self.per_page
        top = bottom + self.per_page
        if top + self.orphans >= self.count:
            top = self.count
        return self._get_page(self.object_list[bottom:top], number, self)

    @cached_property
    def count(self):
        """Return the total number of objects, across all pages."""
        c = getattr(self.object_list, "count", None)
        if callable(c) and not inspect.isbuiltin(c) and method_has_no_args(c):
            return c()
        return len(self.object_list)

    @cached_property
    def num_pages(self):
        """Return the total number of pages."""
        if self.count == 0 and not self.allow_empty_first_page:
            return 0
        hits = max(1, self.count - self.orphans)
        return ceil(hits / self.per_page)

    @property
    def page_range(self):
        """
        Return a 1-based range of pages for iterating through within
        a template for loop.
        """
        return range(1, self.num_pages + 1)

    def get_elided_page_range(self, number=1, *, on_each_side=3, on_ends=2):
        number = self.validate_number(number)
        yield from self._get_elided_page_range(
            number, self.num_pages, self.page_range, on_each_side, on_ends
        )


class AsyncPaginator(BasePaginator):
    def __init__(
        self,
        object_list,
        per_page,
        orphans=0,
        allow_empty_first_page=True,
        error_messages=None,
    ):
        super().__init__(
            object_list, per_page, orphans, allow_empty_first_page, error_messages
        )
        self._cache_acount = None
        self._cache_anum_pages = None

    async def __aiter__(self):
        page_range = await self.apage_range()
        for page_number in page_range:
            yield await self.apage(page_number)

    async def avalidate_number(self, number):
        num_pages = await self.anum_pages()
        return self._validate_number(number, num_pages)

    async def aget_page(self, number):
        """See Paginator.get_page()."""
        try:
            number = await self.avalidate_number(number)
        except PageNotAnInteger:
            number = 1
        except EmptyPage:
            number = await self.anum_pages()
        return await self.apage(number)

    async def apage(self, number):
        """See Paginator.page()."""
        number = await self.avalidate_number(number)
        bottom = (number - 1) * self.per_page
        top = bottom + self.per_page
        count = await self.acount()
        if top + self.orphans >= count:
            top = count

        return self._get_page(self.object_list[bottom:top], number, self)

    def _get_page(self, *args, **kwargs):
        return AsyncPage(*args, **kwargs)

    async def acount(self):
        """See Paginator.count()."""
        if self._cache_acount is not None:
            return self._cache_acount
        c = getattr(self.object_list, "acount", None)
        if (
            iscoroutinefunction(c)
            and not inspect.isbuiltin(c)
            and method_has_no_args(c)
        ):
            count = await c()
        else:
            count = len(self.object_list)

        self._cache_acount = count
        return count

    async def anum_pages(self):
        """See Paginator.num_pages()."""
        if self._cache_anum_pages is not None:
            return self._cache_anum_pages
        count = await self.acount()
        if count == 0 and not self.allow_empty_first_page:
            self._cache_anum_pages = 0
            return self._cache_anum_pages
        hits = max(1, count - self.orphans)
        num_pages = ceil(hits / self.per_page)

        self._cache_anum_pages = num_pages
        return num_pages

    async def apage_range(self):
        """See Paginator.page_range()"""
        num_pages = await self.anum_pages()
        return range(1, num_pages + 1)

    async def aget_elided_page_range(self, number=1, *, on_each_side=3, on_ends=2):
        number = await self.avalidate_number(number)
        num_pages = await self.anum_pages()
        page_range = await self.apage_range()
        for page in self._get_elided_page_range(
            number, num_pages, page_range, on_each_side, on_ends
        ):
            yield page


class Page(collections.abc.Sequence):
    def __init__(self, object_list, number, paginator):
        self.object_list = object_list
        self.number = number
        self.paginator = paginator

    def __repr__(self):
        return "<Page %s of %s>" % (self.number, self.paginator.num_pages)

    def __len__(self):
        return len(self.object_list)

    def __getitem__(self, index):
        if not isinstance(index, (int, slice)):
            raise TypeError(
                "Page indices must be integers or slices, not %s."
                % type(index).__name__
            )
        # The object_list is converted to a list so that if it was a QuerySet
        # it won't be a database hit per __getitem__.
        if not isinstance(self.object_list, list):
            self.object_list = list(self.object_list)
        return self.object_list[index]

    def has_next(self):
        return self.number < self.paginator.num_pages

    def has_previous(self):
        return self.number > 1

    def has_other_pages(self):
        return self.has_previous() or self.has_next()

    def next_page_number(self):
        return self.paginator.validate_number(self.number + 1)

    def previous_page_number(self):
        return self.paginator.validate_number(self.number - 1)

    def start_index(self):
        """
        Return the 1-based index of the first object on this page,
        relative to total objects in the paginator.
        """
        # Special case, return zero if no items.
        if self.paginator.count == 0:
            return 0
        return (self.paginator.per_page * (self.number - 1)) + 1

    def end_index(self):
        """
        Return the 1-based index of the last object on this page,
        relative to total objects found (hits).
        """
        # Special case for the last page because there can be orphans.
        if self.number == self.paginator.num_pages:
            return self.paginator.count
        return self.number * self.paginator.per_page


class AsyncPage:
    def __init__(self, object_list, number, paginator):
        self.object_list = object_list
        self.number = number
        self.paginator = paginator

    def __repr__(self):
        return "<Async Page %s>" % self.number

    async def __aiter__(self):
        if hasattr(self.object_list, "__aiter__"):
            async for obj in self.object_list:
                yield obj
        else:
            for obj in self.object_list:
                yield obj

    def __len__(self):
        if not isinstance(self.object_list, list):
            raise TypeError(
                "AsyncPage.aget_object_list() must be awaited before calling len()."
            )
        return len(self.object_list)

    def __reversed__(self):
        if not isinstance(self.object_list, list):
            raise TypeError(
                "AsyncPage.aget_object_list() "
                "must be awaited before calling reversed()."
            )

        return reversed(self.object_list)

    def __getitem__(self, index):
        if not isinstance(index, (int, slice)):
            raise TypeError(
                "AsyncPage indices must be integers or slices, not %s."
                % type(index).__name__
            )

        if not isinstance(self.object_list, list):
            raise TypeError(
                "AsyncPage.aget_object_list() must be awaited before using indexing."
            )
        return self.object_list[index]

    async def aget_object_list(self):
        """
        Returns self.object_list as a list.

        This method must be awaited before AsyncPage can be
        treated as a sequence of self.object_list.
        """
        if not isinstance(self.object_list, list):
            if hasattr(self.object_list, "__aiter__"):
                self.object_list = [obj async for obj in self.object_list]
            else:
                self.object_list = await sync_to_async(list)(self.object_list)
        return self.object_list

    async def ahas_next(self):
        num_pages = await self.paginator.anum_pages()
        return self.number < num_pages

    async def ahas_previous(self):
        return self.number > 1

    async def ahas_other_pages(self):
        has_previous = await self.ahas_previous()
        has_next = await self.ahas_next()
        return has_previous or has_next

    async def anext_page_number(self):
        return await self.paginator.avalidate_number(self.number + 1)

    async def aprevious_page_number(self):
        return await self.paginator.avalidate_number(self.number - 1)

    async def astart_index(self):
        """See Page.start_index()."""
        count = await self.paginator.acount()
        if count == 0:
            return 0
        return (self.paginator.per_page * (self.number - 1)) + 1

    async def aend_index(self):
        """See Page.end_index()."""
        num_pages = await self.paginator.anum_pages()
        if self.number == num_pages:
            return await self.paginator.acount()
        return self.number * self.paginator.per_page
