"""
Parse iCal data to Events.
"""
# for UID generation
from random import randint
from datetime import datetime, timedelta, date
from typing import Optional

from dateutil.relativedelta import relativedelta
from dateutil.rrule import rrule, rruleset, rrulestr
from dateutil.tz import UTC, gettz

from icalendar import Calendar
from icalendar.prop import vDDDLists, vText
from pytz import timezone


def now():
    """
    Get current time.

    :return: now as datetime with timezone
    """
    return datetime.now(UTC)


class Event:
    """
    Represents one event (occurrence in case of reoccurring events).
    """

    def __init__(self):
        """
        Create a new event occurrence.
        """
        self.uid = -1
        self.summary = None
        self.description = None
        self.start = None
        self.end = None
        self.all_day = True
        self.recurring = False
        self.location = None
        self.private = False
        self.created = None
        self.last_modified = None
        self.sequence = None
        self.attendee = None
        self.organizer = None

    def time_left(self, time=None):
        """
        timedelta form now to event.

        :return: timedelta from now
        """
        time = time or now()
        return self.start - time

    def __lt__(self, other):
        """
        Events are sorted by start time by default.

        :param other: other event
        :return: True if start of this event is smaller than other
        """
        if not other or not isinstance(other, Event):
            raise ValueError('Only events can be compared with each other! Other is %s' % type(other))
        else:
            return self.start < other.start

    def __str__(self):
        n = now()

        # compute time delta description
        if not self.all_day:
            if self.end > n > self.start:
                # event is now
                delta = "now"
            elif self.start > n:
                # event is a future event
                if self.time_left().days > 0:
                    delta = "%s days left" % self.time_left().days
                else:
                    hours = self.time_left().seconds / (60 * 60)
                    delta = "%.1f hours left" % hours
            else:
                # event is over
                delta = "ended"
        else:
            if self.end > n > self.start:
                delta = "today"
            elif self.start > n:
                delta = "%s days left" % self.time_left().days
            else:
                delta = "ended"

        return "%s: %s (%s)" % (self.start, self.summary, delta)

    def copy_to(self, new_start=None, uid=None):
        """
        Create a new event equal to this with new start date.

        :param new_start: new start date
        :param uid: UID of new event
        :return: new event
        """
        if not new_start:
            new_start = self.start

        if not uid:
            uid = "%s_%d" % (self.uid, randint(0, 1000000))

        ne = Event()
        ne.summary = self.summary
        ne.description = self.description
        ne.start = new_start

        if self.end:
            duration = self.end - self.start
            ne.end = (new_start + duration)

        ne.all_day = self.all_day
        ne.recurring = self.recurring
        ne.location = self.location
        ne.attendee = self.attendee
        ne.organizer = self.organizer
        ne.private = self.private
        ne.uid = uid
        ne.created = self.created
        ne.last_modified = self.last_modified

        return ne


def encode(value: Optional[vText]) -> Optional[str]:
    if value is None:
        return None
    try:
        return str(value)
    except UnicodeEncodeError:
        return str(value.encode('utf-8'))


def create_event(component, tz=UTC):
    """
    Create an event from its iCal representation.

    :param component: iCal component
    :param tz: timezone for start and end times
    :return: event
    """

    event = Event()

    event.start = normalize(component.get('dtstart').dt, tz=tz)

    if component.get('dtend'):
        event.end = normalize(component.get('dtend').dt, tz=tz)
    elif component.get('duration'): # compute implicit end as start + duration
        event.end = event.start + component.get('duration').dt
    else: # compute implicit end as start + 0
        event.end = event.start

    event.summary = encode(component.get('summary'))
    event.description = encode(component.get('description'))
    event.all_day = type(component.get('dtstart').dt) is date
    if component.get('rrule'):
        event.recurring = True
    event.location = encode(component.get('location'))

    if component.get('attendee'):
        event.attendee = component.get('attendee')
        if type(event.attendee) is list:
            temp = []
            for a in event.attendee:
                temp.append(a.encode('utf-8').decode('ascii'))
            event.attendee = temp
        else:
            event.attendee = event.attendee.encode('utf-8').decode('ascii')
    else:
        event.attendee = str(None)

    if component.get('uid'):
        event.uid = component.get('uid').encode('utf-8').decode('ascii')

    if component.get('organizer'):
        event.organizer = component.get('organizer').encode('utf-8').decode('ascii')
    else:
        event.organizer = str(None)

    if component.get('class'):
        event_class = component.get('class')
        event.private = event_class == 'PRIVATE' or event_class == 'CONFIDENTIAL'

    if component.get('created'):
        event.created = normalize(component.get('created').dt, tz)

    if component.get('last-modified'):
        event.last_modified = normalize(component.get('last-modified').dt, tz)
    elif event.created:
        event.last_modified = event.created

    if component.get('sequence'):
        event.sequence = component.get('sequence')

    return event


def normalize(dt, tz=UTC):
    """
    Convert date or datetime to datetime with timezone.

    :param dt: date to normalize
    :param tz: the normalized date's timezone
    :return: date as datetime with timezone
    """
    if type(dt) is date:
        dt = dt + relativedelta(hour=0)
    elif type(dt) is datetime:
        pass
    else:
        raise ValueError("unknown type %s" % type(dt))

    if dt.tzinfo:
        dt = dt.astimezone(tz)
    else:
        dt = dt.replace(tzinfo=tz)

    return dt


def parse_events(content, start=None, end=None, default_span=timedelta(days=7)):
    """
    Query the events occurring in a given time range.

    :param content: iCal URL/file content as String
    :param start: start date for search, default today
    :param end: end date for search
    :param default_span: default query length (one week)
    :return: events as list
    """
    if not start:
        start = now()

    if not end:
        end = start + default_span

    if not content:
        raise ValueError('Content is invalid!')

    calendar = Calendar.from_ical(content)

    # Keep track of the timezones defined in the calendar
    timezones = {}
    for c in calendar.walk('VTIMEZONE'):
        name = str(c['TZID'])
        try:
            timezones[name] = c.to_tz()
        except IndexError:
            # This happens if the VTIMEZONE doesn't
            # contain start/end times for daylight
            # saving time. Get the system pytz
            # value from the name as a fallback.
            timezones[name] = timezone(name)

    # If there's exactly one timezone in the file,
    # assume it applies globally, otherwise UTC
    if len(timezones) == 1:
        cal_tz = gettz(list(timezones)[0])
    else:
        cal_tz = UTC

    start = normalize(start, cal_tz)
    end = normalize(end, cal_tz)

    found = []

    # Skip dates that are stored as exceptions.
    exceptions = {}
    for component in calendar.walk():
        if component.name == "VEVENT":
            e = create_event(component, cal_tz)

            if ('EXDATE' in component):
                # Deal with the fact that sometimes it's a list and
                # sometimes it's a singleton
                exlist = []
                if isinstance(component['EXDATE'], list):
                    exlist = component['EXDATE']
                else:
                    exlist.append(component['EXDATE'])
                for ex in exlist:
                    exdate = ex.to_ical().decode("UTF-8")
                    exceptions[exdate[0:8]] = exdate

            # Attempt to work out what timezone is used for the start
            # and end times. If the timezone is defined in the calendar,
            # use it; otherwise, attempt to load the rules from pytz.
            start_tz = None
            end_tz = None

            if e.all_day:
                # Start and end times for all day events must not have
                # a timezone because the specification forbids the
                # RRULE UNTIL from having a timezone. On the other
                # hand, they must be datetime values (not just dates)
                # because RRULE UNTIL will do a comparison against a
                # timezone naive datetime. So we coerce start and end
                # times for all day events into timezone naive
                # datetime values.
                e.start = datetime.combine(e.start.date(), datetime.min.time())
                e.end = datetime.combine(e.end.date(), datetime.min.time())
                start = datetime.combine(start, datetime.min.time())
                end = datetime.combine(end, datetime.min.time())
            else:
                # Work out the staring and ending timezone. We don't do
                # this for all-day appointments because they aren't really
                # in a timezone.
                if e.start.tzinfo != UTC:
                    if str(e.start.tzinfo) in timezones:
                        start_tz = timezones[str(e.start.tzinfo)]
                    else:
                        try:
                            start_tz = timezone(str(e.start.tzinfo))
                        except:
                            pass

                if e.end.tzinfo != UTC:
                    if str(e.end.tzinfo) in timezones:
                        end_tz = timezones[str(e.end.tzinfo)]
                    else:
                        try:
                            end_tz = timezone(str(e.end.tzinfo))
                        except:
                            pass

            # If we've been passed or constructed start/end values
            # that are timezone naive, but the actual appointment
            # start and end times are in a timezone, convert start
            # and end to have a timezone. Otherwise, python will
            # raise an exception for comparing timezone naive
            # and offset-aware values.
            if e.start.tzinfo and not start.tzinfo:
                start = normalize(start, e.start.tzinfo)
            if e.start.tzinfo and not end.tzinfo:
                end = normalize(end, e.start.tzinfo)

            duration = e.end - e.start
            if e.recurring:
                # Unfold recurring events according to their rrule
                rule = parse_rrule(component, cal_tz)
                dur = e.end - e.start
                after = start - dur

                for dt in rule.between(after, end, inc=True):
                    if start_tz is None:
                        # Shrug. If we coudln't work out the timezone, it is what it is.
                        ecopy = e.copy_to(dt, e.uid)
                    else:
                        # Recompute the start time in the current timezone *on* the
                        # date of *this* occurrence. This handles the case where the
                        # recurrence has crossed over the daylight savings time boundary.
                        naive = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)
                        dtstart = start_tz.localize(naive)

                        ecopy = e.copy_to(dtstart, e.uid)

                        # We're effectively looping over the start time, we might need
                        # to adjust the end time too, but don't have it's recurred value.
                        # Make sure it's adjusted by constructing it from the meeting
                        # duration. Pro: it'll be right. Con: if it was in a different
                        # timezone from the start time, we'll have lost that.
                        ecopy.end = dtstart + duration

                    exdate = "%04d%02d%02d" % (ecopy.start.year, ecopy.start.month, ecopy.start.day)
                    if exdate not in exceptions:
                        found.append(ecopy)
            elif e.end >= start and e.start <= end:
                exdate = "%04d%02d%02d" % (e.start.year, e.start.month, e.start.day)
                if exdate not in exceptions:
                    found.append(e)
    return found


def parse_rrule(component, tz=UTC):
    """
    Extract a dateutil.rrule object from an icalendar component. Also includes
    the component's dtstart and exdate properties. The rdate and exrule
    properties are not yet supported.

    :param component: icalendar component
    :param tz: timezone for DST handling
    :return: extracted rrule or rruleset
    """
    if component.get('rrule'):
        # component['rrule'] can be both a scalar and a list
        rrules = component['rrule']
        if not isinstance(rrules, list):
            rrules = [rrules]

        # If dtstart is a datetime, make sure it's in a timezone.
        rdtstart = component['dtstart'].dt
        if type(rdtstart) is datetime:
            rdtstart = normalize(rdtstart, tz=tz)

        # Parse the rrules, might return a rruleset instance, instead of rrule
        rule = rrulestr('\n'.join(x.to_ical().decode() for x in rrules),
                        dtstart=rdtstart)

        if component.get('exdate'):
            # Make sure, to work with a rruleset
            if isinstance(rule, rrule):
                rules = rruleset()
                rules.rrule(rule)
                rule = rules

            # Add exdates to the rruleset
            for exd in extract_exdates(component):
                rule.exdate(exd)

        # TODO: What about rdates and exrules?

    # You really want an rrule for a component without rrule? Here you are.
    else:
        rule = rruleset()
        rule.rdate(normalize(component['dtstart'].dt, tz=tz))

    return rule


def extract_exdates(component):
    """
    Compile a list of all exception dates stored with a component.

    :param component: icalendar iCal component
    :return: list of exception dates
    """
    dates = []

    exd_prop = component.get('exdate')
    if exd_prop:
        if isinstance(exd_prop, list): # In case there is more than one exdate property
            for exd_list in exd_prop:
                dates.extend(normalize(exd.dt) for exd in exd_list.dts)
        elif isinstance(exd_prop, vDDDLists):
            dates.extend(normalize(exd.dt) for exd in exd_prop.dts)

    return dates
