# By: Riasat Ullah
# This class represents a routine layer.

from objects.routine_rotation import RoutineRotation
from utils import constants, helpers, times, var_names
import datetime


class RoutineLayer(object):

    def __init__(self, layer, valid_start, valid_end, is_exception, rotation_start, shift_length, rotation_period,
                 rotation_frequency, skip_days, rotations, start_timestamp=None, end_timestamp=None, layer_name=None):
        '''
        Constructor
        :param layer: int
        :param valid_start: datetime.datetime
        :param valid_end: datetime.datetime
        :param is_exception: boolean
        :param rotation_start: datetime.time
        :param shift_length: datetime.timedelta
        :param rotation_period: (int) total number of days to go through the list of rotations
        :param rotation_frequency: (int) how often it rotates to a new user
        :param skip_days: none or str
        :param rotations: (list) of RoutineRotation objects
        :start_timestamp: (datetime.datetime) timestamp this layer is valid from
        :end_timestamp: (datetime.datetime) timestamp this layer is valid till
        :layer_name: name of the layer
        '''
        self.layer = layer
        self.layer_name = layer_name
        self.valid_start = valid_start
        self.valid_end = valid_end
        self.is_exception = is_exception
        self.rotation_start = rotation_start
        self.shift_length = shift_length
        self.rotation_period = rotation_period
        self.rotation_frequency = rotation_frequency
        self.skip_days = skip_days
        self.rotations = rotations
        self.start_timestamp = start_timestamp
        self.end_timestamp = end_timestamp

    @ staticmethod
    def create_layer(details, for_display=False):
        '''
        Creates a new RoutineLayer object.
        :param details: (dict) dict of policy routines info
        :param for_display: (boolean) True if the Policy(s)/Routine(s) should be mapped on to their reference IDs
        :return: RoutineLayer object
        '''
        rotations = []
        layer_num = details[var_names.layer]
        for item in details[var_names.rotations]:
            rotations.append(RoutineRotation.create_rotation(layer_num, item, for_display))

        valid_start = datetime.datetime.strptime(
            details[var_names.valid_start].split('.')[0], constants.json_timestamp_format)
        valid_end = datetime.datetime.strptime(
            details[var_names.valid_end].split('.')[0], constants.json_timestamp_format)
        rotation_start = details[var_names.rotation_start]
        shift_length_list = details[var_names.shift_length].split(':')
        skip_days = details[var_names.skip_days]

        return RoutineLayer(layer_num, valid_start, valid_end,
                            details[var_names.is_exception],
                            datetime.datetime.strptime(rotation_start, constants.json_time_format).time(),
                            datetime.timedelta(hours=int(shift_length_list[0]), minutes=int(shift_length_list[1])),
                            details[var_names.rotation_period],
                            details[var_names.rotation_frequency],
                            skip_days,
                            rotations,
                            datetime.datetime.strptime(
                                details[var_names.start_timestamp].split('.')[0], constants.json_timestamp_format)
                            if var_names.start_timestamp in details else None,
                            datetime.datetime.strptime(
                                details[var_names.end_timestamp].split('.')[0], constants.json_timestamp_format)
                            if var_names.end_timestamp in details else None,
                            details[var_names.layer_name] if var_names.layer_name in details else None)

    def localize_valid_times(self, routine_timezone):
        '''
        Convert valid times which are in UTC to local times
        :param routine_timezone: timezone the valid start and end dates should be converted to if for display
        :return: (tuple) -> localized valid start, localized valid end
        '''
        self.valid_start = times.utc_to_region_time(self.valid_start, routine_timezone)
        if self.valid_end.date() != constants.end_date:
            self.valid_end = times.utc_to_region_time(self.valid_end, routine_timezone)

        if self.start_timestamp is not None:
            self.start_timestamp = times.utc_to_region_time(self.start_timestamp, routine_timezone)
        if self.end_timestamp is not None and self.end_timestamp.date() != constants.end_date:
            self.end_timestamp = times.utc_to_region_time(self.end_timestamp, routine_timezone)

    def standardize_valid_times_to_utc(self, routine_timezone):
        '''
        Convert valid times from local to UTC.
        :param routine_timezone: timezone the valid start and end dates should be converted from if for display
        :return: (tuple) -> UTC valid start, UTC valid end
        '''
        self.valid_start = times.region_to_utc_time(self.valid_start, routine_timezone)
        if self.valid_end.date() != constants.end_date:
            self.valid_end = times.region_to_utc_time(self.valid_end, routine_timezone)

        if self.start_timestamp is not None:
            self.start_timestamp = times.region_to_utc_time(self.start_timestamp, routine_timezone)
        if self.end_timestamp is not None and self.end_timestamp.date() != constants.end_date:
            self.end_timestamp = times.region_to_utc_time(self.end_timestamp, routine_timezone)

    def get_on_call(self, utc_datetime=times.get_current_timestamp(), routine_timezone='UTC'):
        '''
        Gets the users who are assigned for on-call duty in the routine layer on a given day and time.
        :param utc_datetime: UTC timestamp
        :param routine_timezone: timezone the routine is in
        :return: (list of tuples) -> [(username, display name, assignee policy id), ...] of the users who are on call
        '''
        on_call = []
        region_datetime = times.utc_to_region_time(utc_datetime, routine_timezone)
        region_first_valid_rot_start = datetime.datetime.combine(
            times.utc_to_region_time(self.valid_start, routine_timezone), self.rotation_start)

        rot_start_dt = datetime.datetime.combine(region_datetime.date(), self.rotation_start)
        if rot_start_dt > region_datetime:
            rot_start_dt = rot_start_dt - datetime.timedelta(days=1)
            if rot_start_dt < region_first_valid_rot_start:
                return on_call

        rot_end_dt = rot_start_dt + self.shift_length

        if self.is_available(utc_datetime) and \
            self.is_valid(utc_datetime, rot_start_dt, rot_end_dt, routine_timezone) and \
            rot_start_dt <= region_datetime < rot_end_dt and \
                not self.should_be_skipped(rot_start_dt):

            days_gap = (region_datetime - region_first_valid_rot_start).total_seconds() / (60 * 60 * 24)
            day = (days_gap % self.rotation_period) + 1
            for rotation in self.rotations:
                if rotation.start_period <= day < rotation.end_period:
                    on_call.append((rotation.assignee_name, rotation.display_name, rotation.assignee_policy_id))
        return on_call

    def is_valid(self, check_datetime, reg_rot_start=None, reg_rot_end=None, routine_timezone=None):
        '''
        Checks if a layer is valid at a given point in time.
        :param check_datetime: UTC time
        :param reg_rot_start: region rotation start time
        :param reg_rot_end: region rotation end time
        :param routine_timezone: timezone the routine is in
        :return: (boolean) -> True if the layer is valid; False otherwise
        '''
        if self.valid_start <= check_datetime < self.valid_end:
            return True

        if reg_rot_start is not None and reg_rot_end is not None and routine_timezone is not None and \
                reg_rot_end.date() > reg_rot_start.date():
            reg_valid_end = times.utc_to_region_time(self.valid_end, routine_timezone)
            utc_rot_end = times.region_to_utc_time(reg_rot_end, routine_timezone)
            if (self.valid_start <= check_datetime < utc_rot_end and reg_valid_end.date() == reg_rot_end.date() and
                    (self.end_timestamp is None or
                     (self.end_timestamp is not None and utc_rot_end < self.end_timestamp))):
                return True

        return False

    def is_available(self, check_datetime):
        '''
        Checks if a layer has begun and is still in available. This is based on start and end timestamp;
        not valid start and end times.
        :param check_datetime: UTC timestamp
        :return: (boolean) -> True if the layer is available; False otherwise
        '''
        if self.start_timestamp is not None and self.end_timestamp is not None:
            if self.start_timestamp <= check_datetime < self.end_timestamp:
                return True
            else:
                return False
        return True

    def should_be_skipped(self, region_datetime):
        '''
        Checks if a routine layer should be skipped at given time.
        :param region_datetime: (datetime.datetime) region datetime
        :return: (boolean) True if it should be; False otherwise
        '''
        if self.skip_days is not None and region_datetime.weekday() in self.skip_days:
            return True
        return False

    def get_all_assignees(self):
        '''
        Get the username of all the users who are in the rotation cycle of this level
        :return: (list of tuples) -> [(username, display name, assignee policy id), ...] of users
        '''
        assignees = []
        for rotation in self.rotations:
            assignees.append((rotation.assignee_name, rotation.display_name, rotation.assignee_policy_id))
        return assignees

    def remove_rotations(self, user_names: list):
        '''
        Remove a rotation from the layer.
        :param user_names: (list) of usernames to check for in the rotations and remove
        '''
        new_rotations = []
        start_periods = set()
        for rotation in self.rotations:
            if rotation.assignee_name not in user_names:
                new_rotations.append(rotation)
                start_periods.add(rotation.start_period)
        self.rotations = new_rotations

        # adjust rotations after deletions
        if len(start_periods) > 0:
            ordered_starts = sorted(list(start_periods))
            new_starts = dict()
            for i in range(0, len(ordered_starts)):
                new_starts[ordered_starts[i]] = helpers.get_rotation_start_day(i, self.rotation_frequency)

            for item in self.rotations:
                new_start_day = new_starts[item.start_period]
                item.start_period = new_start_day
                item.end_period = helpers.get_rotation_end_from_start(new_start_day + self.rotation_frequency)

    def get_max_period(self):
        '''
        Gets the maximum period of the rotations in this routine layer.
        This is essentially the number of days a layer can continue without a full rotation.
        :return: (int) -> max period
        '''
        max_period = 0
        for rotation in self.rotations:
            if rotation.end_period > max_period:
                max_period = rotation.end_period
        return max_period

    def prepare_schedule(self, start_date, period):
        '''
        Prepare the schedule for this layer for a period of time.
        ** Pre-condition **: valid times must be localized
        :param start_date: date the schedule should start from
        :param period: number of days the schedule should be prepared for
        :return:
        '''
        if len(self.rotations) == 0:
            return []

        schedule_info = []
        period_start = start_date
        period_end = period_start + datetime.timedelta(days=period)
        period_date = self.valid_start if self.valid_start >= start_date else start_date
        period_date = period_date - datetime.timedelta(days=1)

        self.skip_days = sorted(self.skip_days) if self.skip_days is not None else None
        current_rotation_day = ((period_date - self.valid_start).days % self.rotation_period)
        if current_rotation_day < 0:
            current_rotation_day = 0
        multiplier = self.rotation_frequency - current_rotation_day
        loop_counter = 0    # to prevent an infinite while loop

        prev_period_date = None
        while period_date < period_end and loop_counter <= period:
            on_call = []
            today_weekday = period_date.weekday()

            if self.is_valid(period_date) and (self.skip_days is None or today_weekday not in self.skip_days):
                current_rotation_day = (period_date - self.valid_start).days % self.rotation_period
                for rot in self.rotations:
                    if rot.start_period <= current_rotation_day + 1 < rot.end_period:
                        on_call.append(rot)

                rotation_start_dt = datetime.datetime.combine(period_date, self.rotation_start)
                today_standard_rotation_end_dt = rotation_start_dt + self.shift_length

                rot_end_diff_from_midnight = None
                if today_standard_rotation_end_dt.date() > rotation_start_dt.date():
                    rot_end_diff_from_midnight = today_standard_rotation_end_dt - datetime.datetime.combine(
                        today_standard_rotation_end_dt.date(), datetime.time(0, 0))

                if rot_end_diff_from_midnight is None:
                    days_till_valid_end = (self.valid_end - period_date).total_seconds() / (60 * 60 * 24)
                else:
                    days_till_valid_end = (self.valid_end - period_date + rot_end_diff_from_midnight
                                           ).total_seconds() / (60 * 60 * 24)

                if rotation_start_dt <= self.valid_end and\
                        self.valid_end.date() == today_standard_rotation_end_dt.date():
                    multiplier = 1
                elif days_till_valid_end < multiplier:
                    multiplier = days_till_valid_end

                if self.skip_days is not None and len(self.skip_days) > 0:
                    days_till_next_skip = None
                    for skp in self.skip_days:
                        if today_weekday < skp:
                            days_till_next_skip = skp - today_weekday
                            break
                    if days_till_next_skip is None:
                        days_till_next_skip = (6 - today_weekday) + min(self.skip_days) + 1
                    multiplier = days_till_next_skip

                if self.shift_length == datetime.timedelta(hours=24):
                    rotation_end_dt = rotation_start_dt + (self.shift_length * multiplier)
                    period_date = rotation_end_dt
                else:
                    rotation_end_dt = rotation_start_dt + self.shift_length
                    period_date = period_date + datetime.timedelta(days=1)

                if self.start_timestamp is not None and self.start_timestamp > rotation_start_dt:
                    rotation_start_dt = self.start_timestamp

                # Daylight savings time offset is not needed here because Python keeps the clock intact.
                # It does not just add hours and minutes like date.setHours function in Javascript.

                if rotation_start_dt <= period_start < rotation_end_dt:
                    rotation_start_dt = period_start
                if rotation_end_dt > period_end:
                    rotation_end_dt = period_end
                if rotation_end_dt > self.valid_end and \
                        self.end_timestamp is not None and rotation_end_dt > self.end_timestamp:
                    rotation_end_dt = self.valid_end

                if period_start <= rotation_start_dt < self.valid_end and rotation_start_dt < rotation_end_dt and\
                        rotation_end_dt >= period_start:
                    schedule_info.append({
                        var_names.rotation_start: rotation_start_dt,
                        var_names.rotation_end: rotation_end_dt,
                        var_names.on_call: on_call
                    })
                multiplier = self.rotation_frequency
            else:
                period_date = period_date + datetime.timedelta(days=1)

            loop_counter += 1
            if prev_period_date is None:
                prev_period_date = period_date
            elif prev_period_date == period_date:
                period_date = period_date + datetime.timedelta(days=1)
                prev_period_date = period_date

        return schedule_info

    def to_dict(self, basic_info=False):
        '''
        Gets the dict of the RoutineLayer object.
        :param basic_info: True if only rotation name and assignee preferred username are required from the rotations
        :return: dict of RoutineLayer object.
        '''

        if basic_info:
            rotations_dict = dict()
            for item in self.rotations:
                if item.start_period not in rotations_dict:
                    rotations_dict[item.start_period] = []

                rotations_dict[item.start_period].append((item.display_name, item.assignee_name))

            sorted(rotations_dict.items(), key=lambda s: s[0])
            rotations_list = list(rotations_dict.values())
        else:
            rotations_list = [item.to_dict() for item in self.rotations]
            helpers.sorted_list_of_dict(rotations_list, var_names.start_period)

        shift_len_hours = int(self.shift_length.total_seconds() / (60*60))
        shift_len_minutes = int((self.shift_length.total_seconds() % (60*60)) / 60)
        shift_len_str = str(shift_len_hours).zfill(2) + ':' + str(shift_len_minutes).zfill(2)

        # Rotations data format is different.
        data = {var_names.layer: self.layer,
                var_names.layer_name: self.layer_name,
                var_names.valid_start: self.valid_start,
                var_names.valid_end: self.valid_end,
                var_names.is_exception: self.is_exception,
                var_names.rotation_start: self.rotation_start,
                var_names.shift_length: shift_len_str,
                var_names.rotation_period: self.rotation_period,
                var_names.rotation_frequency: self.rotation_frequency,
                var_names.skip_days: self.skip_days,
                var_names.rotations: rotations_list}

        return data
