"""Compute the times and states of alarms.

This takes different calendar software into account and the RFC 9074 (Alarm Extension).

- RFC 9074 defines an ACKNOWLEDGED property in the VALARM.
- Outlook does not export VALARM information.
- Google Calendar uses the DTSTAMP to acknowledge the alarms.
- Thunderbird snoozes the alarms with a X-MOZ-SNOOZE-TIME attribute in the event.
- Thunderbird acknowledges the alarms with a X-MOZ-LASTACK attribute in the event.
- Etar deletes alarms that are acknowledged.
- Nextcloud's Webinterface does not do anything with the alarms when the time passes.
"""

from __future__ import annotations

from datetime import date, timedelta, tzinfo
from typing import TYPE_CHECKING, Generator, Optional, Union

from icalendar.cal import Alarm, Event, Todo
from icalendar.timezone import tzp
from icalendar.tools import is_date, normalize_pytz, to_datetime

if TYPE_CHECKING:
    from datetime import datetime

Parent = Union[Event,Todo]


class IncompleteAlarmInformation(ValueError):
    """The alarms cannot be calculated yet because information is missing."""

class ComponentStartMissing(IncompleteAlarmInformation):
    """We are missing the start of a component that the alarm is for.

    Use Alarms.set_start().
    """

class ComponentEndMissing(IncompleteAlarmInformation):
    """We are missing the end of a component that the alarm is for.

    Use Alarms.set_end().
    """

class LocalTimezoneMissing(IncompleteAlarmInformation):
    """We are missing the local timezone to compute the value.

    Use Alarms.set_local_timezone().
    """


class AlarmTime:
    """An alarm time with all the information."""

    def __init__(
            self,
            alarm: Alarm,
            trigger : datetime,
            acknowledged_until:Optional[datetime]=None,
            snoozed_until:Optional[datetime]=None,
            parent: Optional[Parent]=None,
        ):
        """Create a new AlarmTime.

        alarm
            the Alarm component

        trigger
            a date or datetime at which to trigger the alarm

        acknowledged_until
            an optional datetime in UTC until when all alarms
            have been acknowledged

        snoozed_until
            an optional datetime in UTC until which all alarms of
            the same parent are snoozed

        parent
            the optional parent component the alarm refers to

        local_tzinfo
            the local timezone that events without tzinfo should have
        """
        self._alarm = alarm
        self._parent = parent
        self._trigger = trigger
        self._last_ack = acknowledged_until
        self._snooze_until = snoozed_until

    @property
    def acknowledged(self) -> Optional[datetime]:
        """The time in UTC at which this alarm was last acknowledged.

        If the alarm was not acknowledged (dismissed), then this is None.
        """
        ack = self.alarm.ACKNOWLEDGED
        if ack is None:
            return self._last_ack
        if self._last_ack is None:
            return ack
        return max(ack, self._last_ack)

    @property
    def alarm(self) -> Alarm:
        """The alarm component."""
        return self._alarm

    @property
    def parent(self) -> Optional[Parent]:
        """This is the component that contains the alarm.

        This is None if you did not use Alarms.set_component().
        """
        return self._parent

    def is_active(self) -> bool:
        """Whether this alarm is active (True) or acknowledged (False).

        For example, in some calendar software, this is True until the user looks
        at the alarm message and clicked the dismiss button.

        Alarms can be in local time (without a timezone).
        To calculate if the alarm really happened, we need it to be in a timezone.
        If a timezone is required but not given, we throw an IncompleteAlarmInformation.
        """
        acknowledged = self.acknowledged
        if not acknowledged:
            # if nothing is acknowledged, this alarm counts
            return True
        if self._snooze_until is not None and self._snooze_until > acknowledged:
            return True
        trigger = self.trigger
        if trigger.tzinfo is None:
            raise LocalTimezoneMissing(
                "A local timezone is required to check if the alarm is still active. "
                "Use Alarms.set_local_timezone()."
            )
        return trigger > acknowledged

    @property
    def trigger(self) -> date:
        """This is the time to trigger the alarm.

        If the alarm has been snoozed, this can differ from the TRIGGER property.
        """
        if self._snooze_until is not None and self._snooze_until > self._trigger:
            return self._snooze_until
        return self._trigger


class Alarms:
    """Compute the times and states of alarms.

    This is an example using RFC 9074.
    One alarm is 30 minutes before the event and acknowledged.
    Another alarm is 15 minutes before the event and still active.

    >>> from icalendar import Event, Alarms
    >>> event = Event.from_ical(
    ... '''BEGIN:VEVENT
    ... CREATED:20210301T151004Z
    ... UID:AC67C078-CED3-4BF5-9726-832C3749F627
    ... DTSTAMP:20210301T151004Z
    ... DTSTART;TZID=America/New_York:20210302T103000
    ... DTEND;TZID=America/New_York:20210302T113000
    ... SUMMARY:Meeting
    ... BEGIN:VALARM
    ... UID:8297C37D-BA2D-4476-91AE-C1EAA364F8E1
    ... TRIGGER:-PT30M
    ... ACKNOWLEDGED:20210302T150004Z
    ... DESCRIPTION:Event reminder
    ... ACTION:DISPLAY
    ... END:VALARM
    ... BEGIN:VALARM
    ... UID:8297C37D-BA2D-4476-91AE-C1EAA364F8E1
    ... TRIGGER:-PT15M
    ... DESCRIPTION:Event reminder
    ... ACTION:DISPLAY
    ... END:VALARM
    ... END:VEVENT
    ... ''')
    >>> alarms = Alarms(event)
    >>> len(alarms.times)   # all alarms including those acknowledged
    2
    >>> len(alarms.active)  # the alarms that are not acknowledged, yet
    1
    >>> alarms.active[0].trigger  # this alarm triggers 15 minutes before 10:30
    datetime.datetime(2021, 3, 2, 10, 15, tzinfo=ZoneInfo(key='America/New_York'))

    RFC 9074 specifies that alarms can also be triggered by proximity.
    This is not implemented yet.
    """

    def __init__(self, component:Optional[Alarm|Event|Todo]=None):
        """Start computing alarm times."""
        self._absolute_alarms : list[Alarm] = []
        self._start_alarms : list[Alarm] = []
        self._end_alarms : list[Alarm] = []
        self._start : Optional[date] = None
        self._end : Optional[date] = None
        self._parent : Optional[Parent] = None
        self._last_ack : Optional[datetime] = None
        self._snooze_until : Optional[datetime] = None
        self._local_tzinfo : Optional[tzinfo] = None

        if component is not None:
            self.add_component(component)

    def add_component(self, component:Alarm|Parent):
        """Add a component.

        If this is an alarm, it is added.
        Events and Todos are added as a parent and all
        their alarms are added, too.
        """
        if isinstance(component, (Event, Todo)):
            self.set_parent(component)
            self.set_start(component.start)
            self.set_end(component.end)
            if component.is_thunderbird():
                self.acknowledge_until(component.X_MOZ_LASTACK)
                self.snooze_until(component.X_MOZ_SNOOZE_TIME)
            else:
                self.acknowledge_until(component.DTSTAMP)

        for alarm in component.walk("VALARM"):
            self.add_alarm(alarm)

    def set_parent(self, parent: Parent):
        """Set the parent of all the alarms.

        If you would like to collect alarms from a component, use add_component
        """
        if self._parent is not None and self._parent is not parent:
            raise ValueError("You can only set one parent for this alarm calculation.")
        self._parent = parent

    def add_alarm(self, alarm: Alarm) -> None:
        """Optional: Add an alarm component."""
        trigger = alarm.TRIGGER
        if trigger is None:
            return
        if isinstance(trigger, date):
            self._absolute_alarms.append(alarm)
        elif alarm.TRIGGER_RELATED == "START":
            self._start_alarms.append(alarm)
        else:
            self._end_alarms.append(alarm)

    def set_start(self, dt: Optional[date]):
        """Set the start of the component.

        If you have only absolute alarms, this is not required.
        If you have alarms relative to the start of a compoment, set the start here.
        """
        self._start = dt

    def set_end(self, dt: Optional[date]):
        """Set the end of the component.

        If you have only absolute alarms, this is not required.
        If you have alarms relative to the end of a compoment, set the end here.
        """
        self._end = dt

    def _add(self, dt: date, td:timedelta):
        """Add a timedelta to a datetime."""
        if is_date(dt):
            if td.seconds == 0:
                return dt + td
            dt = to_datetime(dt)
        return normalize_pytz(dt + td)

    def acknowledge_until(self, dt: Optional[date]) -> None:
        """This is the time in UTC when all the alarms of this component were acknowledged.

        Only the last call counts.

        Since RFC 9074 (Alarm Extension) was created later,
        calendar implementations differ in how they acknowledge alarms.
        For example, Thunderbird and Google Calendar store the last time
        an event has been acknowledged because of an alarm.
        All alarms that happen before this time count as acknowledged.
        """
        self._last_ack = tzp.localize_utc(dt) if dt is not None else None

    def snooze_until(self, dt: Optional[date]) -> None:
        """This is the time in UTC when all the alarms of this component were snoozed.

        Only the last call counts.

        The alarms are supposed to turn up again at dt when they are not acknowledged
        but snoozed.
        """
        self._snooze_until = tzp.localize_utc(dt) if dt is not None else None

    def set_local_timezone(self, tzinfo:Optional[tzinfo|str]):
        """Set the local timezone.

        Events are sometimes in local time.
        In order to compute the exact time of the alarm, some
        alarms without timezone are considered local.

        Some computations work without setting this, others don't.
        If they need this information, expect a LocalTimezoneMissing exception
        somewhere down the line.
        """
        self._local_tzinfo = tzp.timezone(tzinfo) if isinstance(tzinfo, str) else tzinfo

    @property
    def times(self) -> list[AlarmTime]:
        """Compute and return the times of the alarms given.

        If the information for calculation is incomplete, this will raise a
        IncompleteAlarmInformation exception.

        Please make sure to set all the required parameters before calculating.
        If you forget to set the acknowledged times, that is not problem.
        """
        return (
            self._get_end_alarm_times() +
            self._get_start_alarm_times() +
            self._get_absolute_alarm_times()
        )

    def _repeat(self, first: datetime, alarm: Alarm) -> Generator[datetime]:
        """The times when the alarm is triggered relative to start."""
        yield first # we trigger at the start
        repeat = alarm.REPEAT
        duration = alarm.DURATION
        if repeat and duration:
            for i in range(1, repeat + 1):
                yield self._add(first, duration * i)

    def _alarm_time(self, alarm: Alarm, trigger:date):
        """Create an alarm time with the additional attributes."""
        if getattr(trigger, "tzinfo", None) is None and self._local_tzinfo is not None:
            trigger = normalize_pytz(trigger.replace(tzinfo=self._local_tzinfo))
        return AlarmTime(alarm, trigger, self._last_ack, self._snooze_until, self._parent)

    def _get_absolute_alarm_times(self) -> list[AlarmTime]:
        """Return a list of absolute alarm times."""
        return [
            self._alarm_time(alarm , trigger)
            for alarm in self._absolute_alarms
            for trigger in self._repeat(alarm.TRIGGER, alarm)
        ]

    def _get_start_alarm_times(self) -> list[AlarmTime]:
        """Return a list of alarm times relative to the start of the component."""
        if self._start is None and self._start_alarms:
            raise ComponentStartMissing("Use Alarms.set_start because at least one alarm is relative to the start of a component.")
        return [
            self._alarm_time(alarm , trigger)
            for alarm in self._start_alarms
            for trigger in self._repeat(self._add(self._start, alarm.TRIGGER), alarm)
        ]

    def _get_end_alarm_times(self) -> list[AlarmTime]:
        """Return a list of alarm times relative to the start of the component."""
        if self._end is None and self._end_alarms:
            raise ComponentEndMissing("Use Alarms.set_end because at least one alarm is relative to the end of a component.")
        return [
            self._alarm_time(alarm , trigger)
            for alarm in self._end_alarms
            for trigger in self._repeat(self._add(self._end, alarm.TRIGGER), alarm)
        ]

    @property
    def active(self) -> list[AlarmTime]:
        """The alarm times that are still active and not acknowledged.

        This considers snoozed alarms.

        Alarms can be in local time (without a timezone).
        To calculate if the alarm really happened, we need it to be in a timezone.
        If a timezone is required but not given, we throw an IncompleteAlarmInformation.
        """
        return [alarm_time for alarm_time in self.times if alarm_time.is_active()]

__all__ = [
    "Alarms",
    "AlarmTime",
    "IncompleteAlarmInformation",
    "ComponentEndMissing",
    "ComponentStartMissing",
    "LocalTimezoneMissing"
]
