"""Core functionality: querying the calendar for occurrences."""

from __future__ import annotations

import contextlib
import datetime
import itertools
import sys
from typing import TYPE_CHECKING, ClassVar, Generator, Optional, Sequence

try:
    from typing import TypeAlias
except ImportError:
    from typing_extensions import TypeAlias

import icalendar

from recurring_ical_events.adapters.component import ComponentAdapter
from recurring_ical_events.constants import DATE_MAX_DT, DATE_MIN_DT
from recurring_ical_events.errors import (
    BadRuleStringFormat,
    InvalidCalendar,
    PeriodEndBeforeStart,
)
from recurring_ical_events.occurrence import OccurrenceID
from recurring_ical_events.pages import Pages
from recurring_ical_events.selection.base import SelectComponents
from recurring_ical_events.util import compare_greater

if TYPE_CHECKING:
    from icalendar import Component

    from recurring_ical_events.occurrence import Occurrence
    from recurring_ical_events.series import Series
    from recurring_ical_events.types import (
        DateArgument,
        Time,
    )

if sys.version_info >= (3, 10):
    T_COMPONENTS: TypeAlias = Sequence[str | type[ComponentAdapter] | SelectComponents]
else:
    # see https://github.com/python/cpython/issues/86399#issuecomment-1093889925
    T_COMPONENTS: TypeAlias = Sequence[str]


class CalendarQuery:
    """Query a calendar for occurrences.

    Functions like :meth:`at`, :meth:`between` andm :meth:`after`
    can be used to query the selected components.
    If any malformed icalendar information is found,
    an :class:`InvalidCalendar` exception is raised.
    For other bad arguments, you should expect a :class:`ValueError`.

    Attributes:
        suppressed_errors: a list of errors to suppress when
            skip_bad_series is True
    """

    suppressed_errors: ClassVar[type[Exception]] = [
        BadRuleStringFormat,
        PeriodEndBeforeStart,
        icalendar.InvalidCalendar,
    ]
    from recurring_ical_events.selection.name import ComponentsWithName

    def __init__(
        self,
        calendar: Component,
        keep_recurrence_attributes: bool = False,  # noqa: FBT001
        components: T_COMPONENTS = ("VEVENT",),
        skip_bad_series: bool = False,  # noqa: FBT001
    ):
        """Create an unfoldable calendar from a given calendar.

        Arguments:
            calendar: an :class:`icalendar.cal.Calendar` component like
                :class:`icalendar.cal.Calendar`.
            keep_recurrence_attributes: Whether to keep attributes that are only used
                to calculate the recurrence (``RDATE``, ``EXDATE``, ``RRULE``).
            components: A list of component type names of which the recurrences
                should be returned. This can also be instances of
                :class:`SelectComponents`.
                Examples: ``("VEVENT", "VTODO", "VJOURNAL", "VALARM")``
            skip_bad_series: Whether to skip series of components that contain
                errors. You can use :attr:`CalendarQuery.suppressed_errors` to
                specify which errors to skip.
        """
        self.keep_recurrence_attributes = keep_recurrence_attributes
        if calendar.get("CALSCALE", "GREGORIAN") != "GREGORIAN":
            # https://www.kanzaki.com/docs/ical/calscale.html
            raise InvalidCalendar("Only Gregorian calendars are supported.")

        self.series: list[Series] = []  # component
        self._skip_errors = tuple(self.suppressed_errors) if skip_bad_series else ()
        for component_adapter_id in components:
            if isinstance(component_adapter_id, str):
                component_adapter = self.ComponentsWithName(component_adapter_id)
            else:
                component_adapter = component_adapter_id
            self.series.extend(
                component_adapter.collect_series_from(calendar, self._skip_errors)
            )

    @staticmethod
    def to_datetime(date: DateArgument):
        """Convert date inputs of various sorts into a datetime object.

        Arguments:
            date: A date specification.

        Date Specification:

        - a year like ``(2019,)`` or ``2019`` (:class:`int`)
        - a month like ``(2019, 1)`` for January of 2019
        - a day like ``(2019, 1, 19)`` for the first of January 2019
        - a day with hours, ``(2019, 1, 19, 1)``
        - a day with minutes, ``(2019, 1, 19, 13, 30 )``
        - a day with seconds, ``(2019, 1, 19, 13, 30, 59)``
        - a :class:`datetime.datetime` or :class:`datetime.date`
        - a :class:`str` in the format ``yyyymmdd``
        - a :class:`str` in the format ``yyyymmddThhmmssZ``

        """
        if isinstance(date, int):
            date = (date,)
        if isinstance(date, tuple):
            date += (1,) * (3 - len(date))
            return datetime.datetime(*date)  # noqa: DTZ001
        if isinstance(date, str):
            # see https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior
            if len(date) == 8:
                return datetime.datetime.strptime(date, "%Y%m%d")  # noqa: DTZ007
            return datetime.datetime.strptime(date, "%Y%m%dT%H%M%SZ")  # noqa: DTZ007
        return date

    def all(self) -> Generator[Component]:
        """Generate all Components.

        The Components are sorted from the first to the last Occurrence.
        Calendars can contain millions of Occurrences. This iterates
        safely across all of them.
        """
        # MAX and MIN values may change in the future
        return self.after(DATE_MIN_DT)

    _DELTAS = [
        datetime.timedelta(days=1),
        datetime.timedelta(hours=1),
        datetime.timedelta(minutes=1),
        datetime.timedelta(seconds=1),
    ]

    def at(self, date: DateArgument):
        """Return all events within the next 24 hours of starting at the given day.

        Arguments:
            date: A date specification, see :meth:`to_datetime`.

        This is translated to :meth:`between` in the following way:

        - A year returns all occurrences within that year.
            Example: ``(2019,)``, ``2019``
        - A month returns all occurrences within that month.
            Example: ``(2019, 1)``
        - A day returns all occurrences within that day.
            Examples:

            - ``(2019, 1, 19)``
            - ``datetime.date(2019, 1, 19)``
            - ``"20190101"``

        - An hour returns all occurrences within that hour.
            Example: ``(2019, 1, 19, 1)``
        - A minute returns all occurrences within that minute.
            Example: ``(2019, 1, 19, 13, 30 )``
        - A second returns all occurrences at that exact second.
            Examples:

            - ``(2019, 1, 19, 13, 30, 59)``
            - ``datetime.datetime(2019, 1, 19, 13, 30, 59)``
            - ``datetime.datetime(2019, 1, 19, tzinfo=datetime.timezone.utc)``,
            - ``datetime.datetime(2019, 1, 19, 13, 30, 59, tzinfo=ZoneInfo('Europe/London'))``
            - ``"20190119T133059Z"``
        """  # noqa: E501
        if isinstance(date, int):
            date = (date,)
        if isinstance(date, str):
            if len(date) != 8 or not date.isdigit():
                raise ValueError(f"Format yyyymmdd expected for {date!r}.")
            date = (int(date[:4], 10), int(date[4:6], 10), int(date[6:]))
        if isinstance(date, datetime.datetime):
            return self.between(date, date)
        if isinstance(date, datetime.date):
            return self.between(date, date + datetime.timedelta(days=1))
        if len(date) == 1:
            return self.between((date[0], 1, 1), (date[0] + 1, 1, 1))
        if len(date) == 2:
            year, month = date
            if month == 12:
                return self.between((year, 12, 1), (year + 1, 1, 1))
            return self.between((year, month, 1), (year, month + 1, 1))
        dt = self.to_datetime(date)
        return self._between(dt, dt + self._DELTAS[len(date) - 3])

    def between(self, start: DateArgument, stop: DateArgument | datetime.timedelta):
        """Return events at a time between start (inclusive) and end (inclusive)

        Arguments:
            start: A date specification. See :meth:`to_datetime`.
            stop: A date specification or a :class:`datetime.timedelta`
                relative to start.

        .. warning::

            If you pass a :class:`datetime.datetime` to both ``start`` and
            ``stop``, make sure the :attr:`datetime.datetime.tzinfo` is
            the same.
        """
        start = self.to_datetime(start)
        stop = (
            start + stop
            if isinstance(stop, datetime.timedelta)
            else self.to_datetime(stop)
        )
        return self._between(start, stop)

    def _occurrences_to_components(
        self, occurrences: list[Occurrence]
    ) -> list[Component]:
        """Map occurrences to components."""
        return [
            occurrence.as_component(self.keep_recurrence_attributes)
            for occurrence in occurrences
        ]

    def _between(self, start: Time, end: Time) -> list[Component]:
        """Return the occurrences between the start and the end."""
        return self._occurrences_to_components(self._occurrences_between(start, end))

    def _occurrences_between(self, start: Time, end: Time) -> list[Occurrence]:
        """Return the components between the start and the end."""
        occurrences: list[Occurrence] = []
        for series in self.series:
            with contextlib.suppress(self._skip_errors):
                occurrences.extend(series.between(start, end))
        return occurrences

    def after(self, earliest_end: DateArgument) -> Generator[Component]:
        """Iterate over components happening during or after earliest_end.

        Arguments:
            earliest_end: A date specification. See :meth:`to_datetime`.
                Anything happening during or after earliest_end is returned
                in the order of start time.
        """
        earliest_end = self.to_datetime(earliest_end)
        for occurrence in self._after(earliest_end):
            yield occurrence.as_component(self.keep_recurrence_attributes)

    def _after(self, earliest_end: Time) -> Generator[Occurrence]:
        """Iterate over occurrences happening during or after earliest_end."""
        time_span = datetime.timedelta(days=1)
        min_time_span = datetime.timedelta(minutes=15)
        done = False
        result_ids: set[OccurrenceID] = set()

        while not done:
            try:
                next_end = earliest_end + time_span
            except OverflowError:
                # We ran to the end
                next_end = DATE_MAX_DT
                if compare_greater(earliest_end, next_end):
                    return  # we might run too far
                done = True
            occurrences = self._occurrences_between(earliest_end, next_end)
            occurrences.sort()
            for occurrence in occurrences:
                if occurrence.id not in result_ids:
                    yield occurrence
                    result_ids.add(occurrence.id)
            # prepare next query
            time_span = max(
                time_span / 2 if occurrences else time_span * 2,
                min_time_span,
            )  # binary search to improve speed
            earliest_end = next_end

    def count(self) -> int:
        """Return the amount of recurring components in this calendar.

        .. warning::

            Do not use this in production as it generates all occurrences.
        """
        i = 0
        for _ in self.all():
            i += 1
        return i

    @property
    def first(self) -> Component:
        """Return the first recurring component in this calendar.

        Returns:
            The first recurring component in this calendar.

        Raises:
            IndexError: if the calendar is empty
        """
        for component in self.all():
            return component
        raise IndexError("No components found.")

    def paginate(
        self,
        page_size: int,
        earliest_end: Optional[DateArgument] = None,
        latest_start: Optional[DateArgument] = None,
        next_page_id: str = "",
    ) -> Pages:
        """Return pages for pagination.

        Args:
            page_size: the number of components per page
            earliest_end: the start of the first page
                All components occur after this date.
                See :meth:`to_datetime` for possible values.
            latest_start: the end of the last page
                All components occur before this date.
                See :meth:`to_datetime` for possible values.
            next_page_id: The id of the next page.
                This is optional for the first page.
                These are safe to pass outside of the application and back in.
        """
        latest_start = None if latest_start is None else self.to_datetime(latest_start)
        earliest_end = (
            DATE_MIN_DT if earliest_end is None else self.to_datetime(earliest_end)
        )
        if next_page_id:
            first_occurrence_id = OccurrenceID.from_string(next_page_id)
            if not compare_greater(earliest_end, first_occurrence_id.start):
                iterator = self._after(first_occurrence_id.start)
                lost_occurrences = []  # in case we do not find the event
                for occurrence in iterator:
                    lost_occurrences.append(occurrence)
                    oid = occurrence.id
                    if oid == first_occurrence_id:
                        iterator = itertools.chain([occurrence], iterator)
                        break
                    if compare_greater(oid.start, first_occurrence_id.start):
                        iterator = itertools.chain(lost_occurrences, iterator)
                        break
            else:
                iterator = self._after(earliest_end)
        else:
            iterator = self._after(earliest_end)
        return Pages(
            occurrence_iterator=iterator,
            size=page_size,
            stop=latest_start,
            keep_recurrence_attributes=self.keep_recurrence_attributes,
        )


__all__ = ["T_COMPONENTS", "CalendarQuery"]
