import datetime
import enum
import os
import time
import threading

from typing import Optional, Union, Dict, Any, List

import croniter
from dateutil.tz import gettz

from platypush.backend import Backend
from platypush.context import get_bus, get_plugin
from platypush.message.event.alarm import AlarmStartedEvent, AlarmDismissedEvent, AlarmSnoozedEvent
from platypush.plugins.media import MediaPlugin, PlayerState
from platypush.procedure import Procedure


class AlarmState(enum.IntEnum):
    WAITING = 1
    RUNNING = 2
    DISMISSED = 3
    SNOOZED = 4
    SHUTDOWN = 5


class Alarm:
    _alarms_count = 0
    _id_lock = threading.RLock()

    def __init__(self, when: str, actions: Optional[list] = None, name: Optional[str] = None,
                 audio_file: Optional[str] = None, audio_plugin: Optional[str] = None,
                 audio_volume: Optional[Union[int, float]] = None,
                 snooze_interval: float = 300.0, enabled: bool = True):
        with self._id_lock:
            self._alarms_count += 1
            self.id = self._alarms_count

        self.when = when
        self.name = name or 'Alarm_{}'.format(self.id)
        self.audio_file = None

        if audio_file:
            self.audio_file = os.path.abspath(os.path.expanduser(audio_file))
            assert os.path.isfile(self.audio_file), 'No such audio file: {}'.format(self.audio_file)

        self.audio_plugin = audio_plugin
        self.audio_volume = audio_volume
        self.snooze_interval = snooze_interval
        self.state: Optional[AlarmState] = None
        self.timer: Optional[threading.Timer] = None
        self.actions = Procedure.build(name=name, _async=False, requests=actions or [], id=self.id)

        self._enabled = enabled
        self._runtime_snooze_interval = snooze_interval

    def get_next(self) -> float:
        now = datetime.datetime.now().replace(tzinfo=gettz())  # lgtm [py/call-to-non-callable]

        try:
            cron = croniter.croniter(self.when, now)
            return cron.get_next()
        except (AttributeError, croniter.CroniterBadCronError):
            try:
                timestamp = datetime.datetime.fromisoformat(self.when).replace(
                    tzinfo=gettz())  # lgtm [py/call-to-non-callable]
            except (TypeError, ValueError):
                timestamp = (datetime.datetime.now().replace(tzinfo=gettz()) +  # lgtm [py/call-to-non-callable]
                             datetime.timedelta(seconds=int(self.when)))

            return timestamp.timestamp() if timestamp >= now else None

    def is_enabled(self):
        return self._enabled

    def disable(self):
        self._enabled = False

    def enable(self):
        self._enabled = True

    def dismiss(self):
        self.state = AlarmState.DISMISSED
        self.stop_audio()
        get_bus().post(AlarmDismissedEvent(name=self.name))

    def snooze(self, interval: Optional[float] = None):
        self._runtime_snooze_interval = interval or self.snooze_interval
        self.state = AlarmState.SNOOZED
        self.stop_audio()
        get_bus().post(AlarmSnoozedEvent(name=self.name, interval=self._runtime_snooze_interval))

    def start(self):
        if self.timer:
            self.timer.cancel()

        if self.get_next() is None:
            return

        interval = self.get_next() - time.time()
        self.timer = threading.Timer(interval, self.callback())
        self.timer.start()
        self.state = AlarmState.WAITING

    def stop(self):
        self.state = AlarmState.SHUTDOWN
        if self.timer:
            self.timer.cancel()
            self.timer = None

    def _get_audio_plugin(self) -> MediaPlugin:
        return get_plugin(self.audio_plugin)

    def play_audio(self):
        def thread():
            self._get_audio_plugin().play(self.audio_file)
            if self.audio_volume is not None:
                self._get_audio_plugin().set_volume(self.audio_volume)

        self.state = AlarmState.RUNNING
        audio_thread = threading.Thread(target=thread)
        audio_thread.start()

    def stop_audio(self):
        self._get_audio_plugin().stop()

    def callback(self):
        def _callback():
            while True:
                if self.state == AlarmState.SHUTDOWN:
                    break

                if self.is_enabled():
                    get_bus().post(AlarmStartedEvent(name=self.name))
                    if self.audio_plugin and self.audio_file:
                        self.play_audio()

                    self.actions.execute()

                time.sleep(10)
                sleep_time = None
                if self.state == AlarmState.RUNNING:
                    while True:
                        state = self._get_audio_plugin().status().output.get('state')
                        if state == PlayerState.STOP.value:
                            if self.state == AlarmState.SNOOZED:
                                sleep_time = self._runtime_snooze_interval
                            else:
                                self.state = AlarmState.WAITING

                            break
                        else:
                            time.sleep(10)

                if self.state == AlarmState.SNOOZED:
                    sleep_time = self._runtime_snooze_interval
                elif self.get_next() is None:
                    self.state = AlarmState.SHUTDOWN
                    break

                if not sleep_time:
                    sleep_time = self.get_next() - time.time() if self.get_next() else 10

                time.sleep(sleep_time)

        return _callback

    def to_dict(self):
        return {
            'name': self.name,
            'id': self.id,
            'when': self.when,
            'next_run': self.get_next(),
            'enabled': self.is_enabled(),
            'state': self.state.name,
        }


class AlarmBackend(Backend):
    """
    Backend to handle user-configured alarms.

    Triggers:

        * :class:`platypush.message.event.alarm.AlarmStartedEvent` when an alarm starts.
        * :class:`platypush.message.event.alarm.AlarmSnoozedEvent` when an alarm is snoozed.
        * :class:`platypush.message.event.alarm.AlarmTimeoutEvent` when an alarm times out.
        * :class:`platypush.message.event.alarm.AlarmDismissedEvent` when an alarm is dismissed.

    """

    def __init__(self, alarms: Optional[Union[list, Dict[str, Any]]] = None, audio_plugin: str = 'media.mplayer',
                 *args, **kwargs):
        """
        :param alarms: List or name->value dict with the configured alarms. Example:

        .. code-block:: yaml

            morning_alarm:
                when: '0 7 * * 1-5'   # Cron expression format: run every weekday at 7 AM
                audio_file: ~/path/your_ringtone.mp3
                audio_plugin: media.mplayer
                audio_volume: 10       # 10%
                snooze_interval: 300   # 5 minutes snooze
                actions:
                    - action: tts.say
                      args:
                          text: Good morning

                    - action: light.hue.bri
                      args:
                          value: 1

                    - action: light.hue.bri
                      args:
                          value: 140
                          transitiontime: 150

            one_shot_alarm:
                when: '2020-02-18T07:00:00.000000'   # One-shot execution, with timestamp in ISO format
                audio_file: ~/path/your_ringtone.mp3
                actions:
                    - action: light.hue.on

        :param audio_plugin: Media plugin (instance of :class:`platypush.plugins.media.MediaPlugin`) that will be
            used to play the alarm audio (default: ``media.mplayer``).
        """
        super().__init__(*args, **kwargs)
        alarms = alarms or []
        if isinstance(alarms, dict):
            alarms = [{'name': name, **alarm} for name, alarm in alarms.items()]

        self.audio_plugin = audio_plugin
        alarms = [Alarm(**{'audio_plugin': self.audio_plugin, **alarm}) for alarm in alarms]
        self.alarms: Dict[str, Alarm] = {alarm.name: alarm for alarm in alarms}

    def add_alarm(self, when: str, actions: list, name: Optional[str] = None, audio_file: Optional[str] = None,
                  audio_volume: Optional[Union[int, float]] = None, enabled: bool = True) -> Alarm:
        alarm = Alarm(when=when, actions=actions, name=name, enabled=enabled, audio_file=audio_file,
                      audio_plugin=self.audio_plugin, audio_volume=audio_volume)

        if alarm.name in self.alarms:
            self.logger.info('Overwriting existing alarm {}'.format(alarm.name))
            self.alarms[alarm.name].stop()

        self.alarms[alarm.name] = alarm
        self.alarms[alarm.name].start()
        return self.alarms[alarm.name]

    def _get_alarm(self, name) -> Alarm:
        assert name in self.alarms, 'Alarm {} does not exist'.format(name)
        return self.alarms[name]

    def enable_alarm(self, name: str):
        self._get_alarm(name).enable()

    def disable_alarm(self, name: str):
        self._get_alarm(name).disable()

    def dismiss_alarm(self):
        alarm = self.get_running_alarm()
        if not alarm:
            self.logger.info('No alarm is running')
            return

        alarm.dismiss()

    def snooze_alarm(self, interval: Optional[str] = None):
        alarm = self.get_running_alarm()
        if not alarm:
            self.logger.info('No alarm is running')
            return

        alarm.snooze(interval=interval)

    def get_alarms(self) -> List[Alarm]:
        return sorted([alarm for alarm in self.alarms.values()], key=lambda alarm: alarm.get_next())

    def get_running_alarm(self) -> Optional[Alarm]:
        running_alarms = [alarm for alarm in self.alarms.values() if alarm.state == AlarmState.RUNNING]
        return running_alarms[0] if running_alarms else None

    def __enter__(self):
        for alarm in self.alarms.values():
            alarm.stop()
            alarm.start()

        self.logger.info('Initialized alarm backend with {} alarms'.format(len(self.alarms)))

    def __exit__(self, exc_type, exc_val, exc_tb):
        for alarm in self.alarms.values():
            alarm.stop()

        self.logger.info('Alarm backend terminated')

    def loop(self):
        for name, alarm in self.alarms.copy().items():
            if not alarm.timer or (not alarm.timer.is_alive() and alarm.state == AlarmState.SHUTDOWN):
                del self.alarms[name]

        time.sleep(10)


# vim:sw=4:ts=4:et:
