"""This module contains classes relating to Sonos Alarms."""
import logging
import re
from datetime import datetime

from . import discovery
from .core import _SocoSingletonBase, PLAY_MODES
from .exceptions import SoCoException
from .xml import XML

log = logging.getLogger(__name__)
TIME_FORMAT = "%H:%M:%S"


def is_valid_recurrence(text):
    """Check that ``text`` is a valid recurrence string.

    A valid recurrence string is  ``DAILY``, ``ONCE``, ``WEEKDAYS``,
    ``WEEKENDS`` or of the form ``ON_DDDDDD`` where ``D`` is a number from 0-6
    representing a day of the week (Sunday is 0), e.g. ``ON_034`` meaning
    Sunday, Wednesday and Thursday

    Args:
        text (str): the recurrence string to check.

    Returns:
        bool: `True` if the recurrence string is valid, else `False`.

    Examples:

        >>> from soco.alarms import is_valid_recurrence
        >>> is_valid_recurrence('WEEKENDS')
        True
        >>> is_valid_recurrence('')
        False
        >>> is_valid_recurrence('ON_132')  # Mon, Tue, Wed
        True
        >>> is_valid_recurrence('ON_666')  # Sat
        True
        >>> is_valid_recurrence('ON_3421') # Mon, Tue, Wed, Thur
        True
        >>> is_valid_recurrence('ON_123456789') # Too many digits
        False
    """
    if text in ("DAILY", "ONCE", "WEEKDAYS", "WEEKENDS"):
        return True
    return re.search(r"^ON_[0-6]{1,7}$", text) is not None


class Alarms(_SocoSingletonBase):
    """A class representing all known Sonos Alarms.

    Is a singleton and every `Alarms()` object will return the same instance.

    Example use:

        >>> get_alarms()
        {469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
         470: <Alarm id:470@22:07:46 at 0x7f5198797d60>}
        >>> alarms = Alarms()
        >>> alarms.update()
        >>> alarms.alarms
        {469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
         470: <Alarm id:470@22:07:46 at 0x7f5198797d60>}
        >>> for alarm in alarms:
        ...     alarm
        ...
        <Alarm id:469@22:07:41 at 0x7f5198797dc0>
        <Alarm id:470@22:07:46 at 0x7f5198797d60>
        >>> alarms[470]
        <Alarm id:470@22:07:46 at 0x7f5198797d60>
        >>> new_alarm = Alarm(zone)
        >>> new_alarm.save()
        471
        >>> new_alarm.recurrence = "ONCE"
        >>> new_alarm.save()
        471
        >>> alarms.alarms
        {469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
         470: <Alarm id:470@22:07:46 at 0x7f5198797d60>,
         471: <Alarm id:471@22:08:40 at 0x7f51987f1b50>}
        >>> alarms[470].remove()
        >>> alarms.alarms
        {469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
         471: <Alarm id:471@22:08:40 at 0x7f51987f1b50>}
        >>> for alarm in alarms:
        ...     alarm.remove()
        ...
        >>> a.alarms
        {}
    """

    _class_group = "Alarms"

    def __init__(self):
        """Initialize the instance."""
        self.alarms = {}
        self._last_zone_used = None
        self._last_alarm_list_version = None
        self.last_uid = None
        self.last_id = 0

    @property
    def last_alarm_list_version(self):
        """Return last seen alarm list version."""
        return self._last_alarm_list_version

    @last_alarm_list_version.setter
    def last_alarm_list_version(self, alarm_list_version):
        """Store alarm list version and store UID/ID values."""
        self.last_uid, last_id = alarm_list_version.split(":")
        self.last_id = int(last_id)
        self._last_alarm_list_version = alarm_list_version

    def __iter__(self):
        """Return an interator for all alarms."""
        for alarm in list(self.alarms.values()):
            yield alarm

    def __len__(self):
        """Return the number of alarms."""
        return len(self.alarms)

    def __getitem__(self, alarm_id):
        """Return the alarm by ID."""
        return self.alarms[alarm_id]

    def get(self, alarm_id):
        """Return the alarm by ID or None."""
        return self.alarms.get(alarm_id)

    def update(self, zone=None):
        """Update all alarms and current alarm list version.

        Raises:
            SoCoException: If the 'CurrentAlarmListVersion' value is unexpected.
                May occur if the provided zone is from a different household.
        """
        if zone is None:
            zone = self._last_zone_used or discovery.any_soco()

        self._last_zone_used = zone

        response = zone.alarmClock.ListAlarms()
        current_alarm_list_version = response["CurrentAlarmListVersion"]

        if self.last_alarm_list_version:
            alarm_list_uid, alarm_list_id = current_alarm_list_version.split(":")
            if self.last_uid != alarm_list_uid:
                matching_zone = next(
                    (z for z in zone.all_zones if z.uid == alarm_list_uid), None
                )
                if not matching_zone:
                    raise SoCoException(
                        "Alarm list UID {} does not match {}".format(
                            current_alarm_list_version, self.last_alarm_list_version
                        )
                    )

            if int(alarm_list_id) <= self.last_id:
                return

        self.last_alarm_list_version = current_alarm_list_version

        new_alarms = parse_alarm_payload(response, zone)

        # Update existing and create new Alarm instances
        for alarm_id, kwargs in new_alarms.items():
            existing_alarm = self.alarms.get(alarm_id)
            if existing_alarm:
                existing_alarm.update(**kwargs)
            else:
                new_alarm = Alarm(**kwargs)
                new_alarm._alarm_id = alarm_id  # pylint: disable=protected-access
                self.alarms[alarm_id] = new_alarm

        # Prune alarms removed externally
        for alarm_id in list(self.alarms):
            if not new_alarms.get(alarm_id):
                self.alarms.pop(alarm_id)


class Alarm:

    """A class representing a Sonos Alarm.

    Alarms may be created or updated and saved to, or removed from the Sonos
    system. An alarm is not automatically saved. Call `save()` to do that.
    """

    # pylint: disable=too-many-instance-attributes
    # pylint: disable=too-many-arguments
    def __init__(
        self,
        zone,
        start_time=None,
        duration=None,
        recurrence="DAILY",
        enabled=True,
        program_uri=None,
        program_metadata="",
        play_mode="NORMAL",
        volume=20,
        include_linked_zones=False,
    ):
        """
        Args:
            zone (`SoCo`): The soco instance which will play the alarm.
            start_time (datetime.time, optional): The alarm's start time.
                Specify hours, minutes and seconds only. Defaults to the
                current time.
            duration (datetime.time, optional): The alarm's duration. Specify
                hours, minutes and seconds only. May be `None` for unlimited
                duration. Defaults to `None`.
            recurrence (str, optional): A string representing how
                often the alarm should be triggered. Can be ``DAILY``,
                ``ONCE``, ``WEEKDAYS``, ``WEEKENDS`` or of the form
                ``ON_DDDDDD`` where ``D`` is a number from 0-6 representing a
                day of the week (Sunday is 0), e.g. ``ON_034`` meaning Sunday,
                Wednesday and Thursday. Defaults to ``DAILY``.
            enabled (bool, optional): `True` if alarm is enabled, `False`
                otherwise. Defaults to `True`.
            program_uri(str, optional): The uri to play. If `None`, the
                built-in Sonos chime sound will be used. Defaults to `None`.
            program_metadata (str, optional): The metadata associated with
                'program_uri'. Defaults to ''.
            play_mode(str, optional): The play mode for the alarm. Can be one
                of ``NORMAL``, ``SHUFFLE_NOREPEAT``, ``SHUFFLE``,
                ``REPEAT_ALL``, ``REPEAT_ONE``, ``SHUFFLE_REPEAT_ONE``.
                Defaults to ``NORMAL``.
            volume (int, optional): The alarm's volume (0-100). Defaults to 20.
            include_linked_zones (bool, optional): `True` if the alarm should
                be played on the other speakers in the same group, `False`
                otherwise. Defaults to `False`.
        """

        self.zone = zone
        if start_time is None:
            start_time = datetime.now().time().replace(microsecond=0)
        self.start_time = start_time
        self.duration = duration
        self.recurrence = recurrence
        self.enabled = enabled
        self.program_uri = program_uri
        self.program_metadata = program_metadata
        self.play_mode = play_mode
        self.volume = volume
        self.include_linked_zones = include_linked_zones
        self._alarm_id = None

    def __repr__(self):
        middle = str(self.start_time.strftime(TIME_FORMAT))
        return "<{} id:{}@{} at {}>".format(
            self.__class__.__name__, self.alarm_id, middle, hex(id(self))
        )

    def update(self, **kwargs):
        """Update an existing Alarm instance using the same arguments as __init__."""
        for attr, value in kwargs.items():
            if not hasattr(self, attr):
                raise SoCoException("Alarm does not have atttribute {}".format(attr))
            setattr(self, attr, value)

    @property
    def play_mode(self):
        """
        `str`: The play mode for the alarm.

            Can be one of ``NORMAL``, ``SHUFFLE_NOREPEAT``, ``SHUFFLE``,
            ``REPEAT_ALL``, ``REPEAT_ONE``, ``SHUFFLE_REPEAT_ONE``.
        """
        return self._play_mode

    @play_mode.setter
    def play_mode(self, play_mode):
        """See `playmode`."""
        play_mode = play_mode.upper()
        if play_mode not in PLAY_MODES:
            raise KeyError("'%s' is not a valid play mode" % play_mode)
        self._play_mode = play_mode

    @property
    def volume(self):
        """`int`: The alarm's volume (0-100)."""
        return self._volume

    @volume.setter
    def volume(self, volume):
        """See `volume`."""
        # max 100
        volume = int(volume)
        self._volume = max(0, min(volume, 100))  # Coerce in range

    @property
    def recurrence(self):
        """`str`: How often the alarm should be triggered.

        Can be ``DAILY``, ``ONCE``, ``WEEKDAYS``, ``WEEKENDS`` or of the form
        ``ON_DDDDDDD`` where ``D`` is a number from 0-7 representing a day of
        the week (Sunday is 0), e.g. ``ON_034`` meaning Sunday, Wednesday and
        Thursday.
        """
        return self._recurrence

    @recurrence.setter
    def recurrence(self, recurrence):
        """See `recurrence`."""
        if not is_valid_recurrence(recurrence):
            raise KeyError("'%s' is not a valid recurrence value" % recurrence)

        self._recurrence = recurrence

    def save(self):
        """Save the alarm to the Sonos system.

        Returns:
            str: The alarm ID, or `None` if no alarm was saved.

        Raises:
            ~soco.exceptions.SoCoUPnPException: if the alarm cannot be created
                because there
                is already an alarm for this room at the specified time.
        """
        args = [
            ("StartLocalTime", self.start_time.strftime(TIME_FORMAT)),
            (
                "Duration",
                "" if self.duration is None else self.duration.strftime(TIME_FORMAT),
            ),
            ("Recurrence", self.recurrence),
            ("Enabled", "1" if self.enabled else "0"),
            ("RoomUUID", self.zone.uid),
            (
                "ProgramURI",
                "x-rincon-buzzer:0" if self.program_uri is None else self.program_uri,
            ),
            ("ProgramMetaData", self.program_metadata),
            ("PlayMode", self.play_mode),
            ("Volume", self.volume),
            ("IncludeLinkedZones", "1" if self.include_linked_zones else "0"),
        ]
        if self.alarm_id is None:
            response = self.zone.alarmClock.CreateAlarm(args)
            self._alarm_id = response["AssignedID"]
            alarms = Alarms()
            if alarms.last_id == int(self.alarm_id) - 1:
                alarms.last_alarm_list_version = "{}:{}".format(
                    alarms.last_uid, self.alarm_id
                )
            alarms.alarms[self.alarm_id] = self
        else:
            # The alarm has been saved before. Update it instead.
            args.insert(0, ("ID", self.alarm_id))
            self.zone.alarmClock.UpdateAlarm(args)
        return self.alarm_id

    def remove(self):
        """Remove the alarm from the Sonos system.

        There is no need to call `save`. The Python instance is not deleted,
        and can be saved back to Sonos again if desired.

        Returns:
            bool: If the removal was sucessful.
        """
        result = self.zone.alarmClock.DestroyAlarm([("ID", self.alarm_id)])
        alarms = Alarms()
        alarms.alarms.pop(self.alarm_id, None)
        self._alarm_id = None
        return result

    @property
    def alarm_id(self):
        """`str`: The ID of the alarm, or `None`."""
        return self._alarm_id


def get_alarms(zone=None):
    """Get a set of all alarms known to the Sonos system.

    Args:
        zone (soco.SoCo, optional): a SoCo instance to query. If None, a random
            instance is used. Defaults to `None`.

    Returns:
        set: A set of `Alarm` instances
    """
    alarms = Alarms()
    alarms.update(zone)
    return set(alarms.alarms.values())


def remove_alarm_by_id(zone, alarm_id):
    """Remove an alarm from the Sonos system by its ID.

    Args:
        zone (`SoCo`): A SoCo instance, which can be any zone that belongs
            to the Sonos system in which the required alarm is defined.
        alarm_id (str): The ID of the alarm to be removed.

    Returns:
        bool: `True` if the alarm is found and removed, `False` otherwise.
    """
    alarms = Alarms()
    alarms.update(zone)
    alarm = alarms.get(alarm_id)
    if not alarm:
        return False
    return alarm.remove()


def parse_alarm_payload(payload, zone):
    """Parse the XML payload response and return a dict of `Alarm` kwargs."""
    alarm_list = payload["CurrentAlarmList"]
    tree = XML.fromstring(alarm_list.encode("utf-8"))

    # An alarm list looks like this:
    # <Alarms>
    #     <Alarm ID="14" StartTime="07:00:00"
    #         Duration="02:00:00" Recurrence="DAILY" Enabled="1"
    #         RoomUUID="RINCON_000ZZZZZZ1400"
    #         ProgramURI="x-rincon-buzzer:0" ProgramMetaData=""
    #         PlayMode="SHUFFLE_NOREPEAT" Volume="25"
    #         IncludeLinkedZones="0"/>
    #     <Alarm ID="15" StartTime="07:00:00"
    #         Duration="02:00:00" Recurrence="DAILY" Enabled="1"
    #         RoomUUID="RINCON_000ZZZZZZ01400"
    #         ProgramURI="x-rincon-buzzer:0" ProgramMetaData=""
    #         PlayMode="SHUFFLE_NOREPEAT" Volume="25"
    #          IncludeLinkedZones="0"/>
    # </Alarms>

    alarms = tree.findall("Alarm")
    alarm_args = {}
    for alarm in alarms:
        values = alarm.attrib
        alarm_id = values["ID"]

        alarm_zone = next(
            (z for z in zone.all_zones if z.uid == values["RoomUUID"]), None
        )
        if alarm_zone is None:
            # Some alarms are not associated with a zone, ignore these
            continue

        args = {
            "zone": alarm_zone,
            # StartTime not StartLocalTime which is used by CreateAlarm
            "start_time": datetime.strptime(values["StartTime"], "%H:%M:%S").time(),
            "duration": (
                None
                if values["Duration"] == ""
                else datetime.strptime(values["Duration"], "%H:%M:%S").time()
            ),
            "recurrence": values["Recurrence"],
            "enabled": values["Enabled"] == "1",
            "program_uri": (
                None
                if values["ProgramURI"] == "x-rincon-buzzer:0"
                else values["ProgramURI"]
            ),
            "program_metadata": values["ProgramMetaData"],
            "play_mode": values["PlayMode"],
            "volume": values["Volume"],
            "include_linked_zones": values["IncludeLinkedZones"] == "1",
        }

        alarm_args[alarm_id] = args
    return alarm_args
