# By: Riasat Ullah
# This module contains a variety of functions that act as helping tools.

from taskcallrest import settings
from translators import label_translator as _lt
from utils import constants, info, label_names as lnm, logging, times, var_names
from validations import string_validator
import configuration as configs
import datetime
import json
import re
import requests
import uuid


def get_api_request(url, authorization_token=None, get_status_only=False):
    '''
    Makes a GET request to the api and returns the response.
    :param url: api url
    :param authorization_token: the token that can be used to verify the request
    :param get_status_only: option to specify if only the status code is wanted or not
    :return: [status code, response]
    '''
    try:
        header_params = {
            'Accept': 'application/json',
            'Content-Type': 'application/json',
        }
        if authorization_token is not None:
            header_params['Authorization'] = var_names.token + " " + authorization_token

        response = requests.get(url, headers=header_params)

        if get_status_only:
            return response.status_code

        return [response.status_code, response.json()]
    except Exception as e:
        logging.exception('Could not fetch data from api...')
        logging.exception(str(e))
        raise


def post_api_request(url, body, authorization_token=None, get_status_only=False,
                     content_type=constants.content_type_json, accept_type=constants.content_type_json):
    '''
    Makes a POST request to the api and returns the response.
    :param url: api url
    :param body: the body/params of the request
    :param authorization_token: the token that can be used to verify the request
    :param get_status_only: option to specify if only the status code is wanted or not
    :param content_type: ContentType of the request -> 'application/json', 'application/x-www-form-urlencoded', etc
    :param accept_type: ContentType of the request -> 'application/json', 'application/x-www-form-urlencoded', etc
    :return: [status code, response]
    '''
    try:
        header_params = {
            'Accept': accept_type,
            'Content-Type': content_type,
        }
        if authorization_token is not None:
            header_params['Authorization'] = var_names.token + " " + authorization_token

        response = requests.post(url, headers=header_params,
                                 data=json.dumps(body) if content_type == constants.content_type_json else body)

        if get_status_only:
            return response.status_code

        return [response.status_code, response.json()]
    except Exception as e:
        logging.exception('Could not fetch data from api...')
        logging.exception(str(e))
        raise


def get_int_list(int_or_list):
    '''
    Get a list of ints.
    :param int_or_list: int or list of int
    :return: (list) of int
    '''
    if isinstance(int_or_list, list):
        for item in int_or_list:
            assert isinstance(item, int)
        return int_or_list
    elif isinstance(int_or_list, int):
        return [int_or_list]
    else:
        raise TypeError('Expected int or list of int. Found ' + str(type(int_or_list)))


def get_string_list(string_or_list, check_sql_injection=False, check_for_email=False):
    '''
    Converts a str or list of str to a string
    :param string_or_list: str or list of str
    :param check_sql_injection: (boolean) if each item should be checked for sql injection or not
    :param check_for_email: (boolean) if each item should be checked for being a valid email
    :return: (list) of strings
    '''
    bucket = []
    if isinstance(string_or_list, list):
        bucket = string_or_list
    else:
        if isinstance(string_or_list, str):
            bucket.append(string_or_list)
        else:
            raise TypeError('Expected string or list of str. Found ' + str(type(string_or_list)))

    for item in bucket:
        assert isinstance(item, str)

        # check for sql injection; if specified
        if check_sql_injection:
            assert string_validator.is_not_sql_injection(item)
        # check if valid email; if specified
        if check_for_email:
            assert string_validator.is_email_address(item)
    return bucket


def intlist_to_string(intlist):
    '''
    Converts an int or list of int to a string.
    :param intlist: int or list of int
    :return: string with items separated by comma
    '''
    bucket = []
    if intlist in [True, False]:
        raise TypeError('Boolean passed. Cannot process')

    if isinstance(intlist, int):
        bucket.append(intlist)
    elif isinstance(intlist, list):
        for item in intlist:
            assert isinstance(item, int)
        bucket = intlist
    else:
        raise TypeError('Expected int or list of int. Found ' + str(type(intlist)))
    return ','.join(str(x) for x in bucket)


def stringlist_to_string(stringlist, check_sql_injection=False, check_for_email=False):
    '''
    Converts a str or list of str to a string
    :param stringlist: str or list of str
    :param check_sql_injection: (boolean) if each item should be checked for sql injection or not
    :param check_for_email: (boolean) if each item should be checked for being a valid email
    :return: a string with items separated by comma
    '''
    bucket = []
    if isinstance(stringlist, str):
        bucket.append(stringlist)
    elif isinstance(stringlist, list):
        bucket = stringlist
    else:
        raise TypeError('Expected string or list of str. Found ' + str(type(stringlist)))

    for item in bucket:
        assert isinstance(item, str)
        # check for sql injection; if specified
        if check_sql_injection:
            assert string_validator.is_not_sql_injection(item)
        # check if valid email; if specified
        if check_for_email:
            assert string_validator.is_email_address(item)
    return ','.join("'" + x + "'" for x in bucket)


def get_minutes_diff_from_timestamps(start_time, end_time):
    '''
    Get the difference in minutes.
    :param start_time: (datetime.datetime) the start time
    :param end_time: (datetime.datetime) the end time
    :return: (int) minutes
    '''
    return round((end_time - start_time).total_seconds() / 60, 2)


def get_info_from_ip_address(ip_address: str, attribute: str):
    '''
    Gets the ISO country code of an ip address
    :param ip_address: (str) ip address to check for
    :param attribute: (str) the attribute that is being checked for
    :return: requested attribute
    :errors: KeyError
    '''
    try:
        full_address = '/'.join([constants.ip_library_site + ip_address])
        response = requests.get(full_address).json()
        if attribute in response:
            return response[attribute]
        else:
            if attribute == constants.ip_timezone_attribute:
                return configs.standard_timezone
            raise KeyError('Unknown attribute - ' + attribute)
    except Exception as e:
        logging.exception(str(e))
        return configs.standard_timezone


def get_rotation_start_day(index, frequency):
    '''
    Get the start day of a particular rotation.
    :param index: index of the rotation in the list
    :param frequency: the frequency by which the rotations will change
    :return: (int) new start day
    '''
    return 1 + (index * frequency)


def get_rotation_end_day(index, frequency):
    '''
    Get the end day of a particular rotation.
    :param index: index of the rotation in the list
    :param frequency: the frequency by which the rotations will change
    :return: (int) new start day
    '''
    return 1 + frequency + (index * frequency)


def get_rotation_end_from_start(start, frequency):
    '''
    Get the end day of a particular rotation given the start day.
    :param start: the start day of the rotation
    :param frequency: the frequency by which the rotations will change
    :return: (int) new start day
    '''
    return start + frequency


def construct_taskcall_email_address(preferred_username, subdomain):
    '''
    Constructs the email address for a user's taskcall email.
    :param preferred_username: the preferred username of the user
    :param subdomain: the subdomain of the user's organization
    :return: (str) -> email address
    '''
    if settings.TEST_SERVER:
        domain = constants.regional_test_server_urls[settings.REGION][var_names.domain]
    else:
        domain = constants.regional_urls[settings.REGION][var_names.domain]
    return preferred_username + '@' + subdomain + '.' + domain


def construct_organization_full_domain(subdomain):
    '''
    Constructs the full taskcall domain of an organization.
    :param subdomain: the subdomain of the organization
    :return: (str) -> {subdomain}.taskcallapp.com
    '''
    if settings.TEST_SERVER:
        domain = constants.regional_test_server_urls[settings.REGION][var_names.domain]
    else:
        domain = constants.regional_urls[settings.REGION][var_names.domain]
    return subdomain + '.' + domain


def sorted_list_of_dict(dict_list, sort_by_key, descending=False):
    '''
    Sorts a list of dict by a given key
    :param dict_list: list of dict objects
    :param sort_by_key: key to sort by
    :param descending: True if the list should be sorted in descending order
    :return: sorted list of dict
    '''
    return sorted(dict_list, key=lambda k: k[sort_by_key], reverse=descending)


def get_max_value_from_tuple_list(tuple_list, index):
    '''
    Gets the maximum value of a particular index from a list of tuples.
    :param tuple_list: list of tuples
    :param index: the index of the tuple to search from
    :return: max value
    '''
    value = None
    for i in range(0, len(tuple_list)):
        if i == 0:
            value = tuple_list[i][index]
        else:
            if tuple_list[i][index] > value:
                value = tuple_list[index]
    return value


def get_min_value_from_tuple_list(tuple_list, index):
    '''
    Gets the maximum value of a particular index from a list of tuples.
    :param tuple_list: list of tuples
    :param index: the index of the tuple to search from
    :return: min value
    '''
    value = None
    for i in range(0, len(tuple_list)):
        if i == 0:
            value = tuple_list[i][index]
        else:
            if tuple_list[i][index] < value:
                value = tuple_list[index]
    return value


def generate_routine_layers_json(layers, user_pref_id_map, with_pg_format_array=False, timezone=None):
    '''
    Gets the json format of layers data needed for creating routine layers
    using the create_routine function in the db.
    :param layers: (list) of layers data
    :param user_pref_id_map: (dict) -> {pref name: user ID, ...}
    :param with_pg_format_array: True if the array should be in postgres format
    :param timezone: the timezone region the routine layers are in
    :return: (json) of routine layers data
    '''
    layers_struct = layers

    for item in layers_struct:
        v_start = item[var_names.valid_start]
        v_start = datetime.datetime.combine(times.get_date_from_string(v_start), datetime.time(0, 0))
        item[var_names.valid_start] = times.region_to_utc_time(v_start, timezone).strftime(constants.timestamp_format)

        v_end = item[var_names.valid_end]
        if v_end is None:
            item[var_names.valid_end] = constants.end_timestamp_str
        else:
            v_end = datetime.datetime.combine(times.get_date_from_string(v_end), datetime.time(0, 0))
            if v_end == constants.end_timestamp:
                item[var_names.valid_end] = constants.end_timestamp_str
            else:
                item[var_names.valid_end] = times.region_to_utc_time(v_end, timezone)\
                    .strftime(constants.timestamp_format)

        item[var_names.rotation_period] = item[var_names.rotation_frequency] * len(item[var_names.rotations])

        rotations_list = item[var_names.rotations]
        frequency = item[var_names.rotation_frequency]

        rotation_items = []
        for i in range(0, len(rotations_list)):
            for pref_name in rotations_list[i]:
                rotation_items.append({
                    var_names.assignee_id: user_pref_id_map[pref_name],
                    var_names.start_period: get_rotation_start_day(i, frequency),
                    var_names.end_period: get_rotation_end_day(i, frequency),
                })

        item[var_names.rotations] = rotation_items

        if item[var_names.skip_days] is not None and with_pg_format_array:
            pg_format_arr = '{' + ', '.join(str(x) for x in item[var_names.skip_days]) + '}'
            item[var_names.skip_days] = pg_format_arr

    return json.dumps(layers_struct)


def get_youngest_child_policies_on_call(level, check_datetime, policy, policies_dict, youngest_children):
    '''
    Get the youngest children policies of a Policy object.
    :param level: level number
    :param check_datetime: the checking datetime
    :param policy: the Policy object
    :param policies_dict: (dict) of Policy objects to look from
    :param youngest_children: (list) of the youngest User policies
    :return: (list) of the youngest User policies
    '''
    if policy.policy_type == constants.user_policy:
        youngest_children.append(policy)
        return youngest_children
    else:
        on_call = policy.get_on_call(level, check_datetime)
        for item in on_call:
            if item[0] in policies_dict:
                new_policy = policies_dict[item[0]]
                youngest_children = get_youngest_child_policies_on_call(level, check_datetime, new_policy,
                                                                        policies_dict, youngest_children)
        return youngest_children


def get_new_instance_assignments_struct(timestamp, assignee_policy):
    '''
    Get the list of dicts that can be converted to json to insert new instance assignment entries.
    :param timestamp: timestamp
    :param assignee_policy: the Policy object of the assignee
    :return: (tuple) -> available level, (list) of dict -> [{user_policyid: usr_pol, for_policyid: for_pid}]
    '''
    avl_level, avl_on_calls = assignee_policy.get_next_available_level_on_call(level_number=1, check_datetime=timestamp)
    on_call_user_pids = [x[2] for x in avl_on_calls]

    structs = []
    for user_pid in on_call_user_pids:
        structs.append({
            var_names.user_policyid: user_pid,
            var_names.for_policyid: assignee_policy.policy_id
        })
    return avl_level, structs


def is_date_str_or_date(date_obj):
    '''
    Checks if an object is a date string or a date object or not.
    :param date_obj: the object to check
    :return: (boolean) True if it is; False otherwise
    '''
    try:
        if isinstance(date_obj, str):
            if '-' in date_obj:
                datetime.datetime.strptime(date_obj, constants.date_hyphen_format)
            else:
                datetime.datetime.strptime(date_obj, constants.date_format)
        else:
            assert isinstance(date_obj, datetime.date)
        return True
    except (AssertionError, TypeError, ValueError):
        return False


def create_triggered_task_levels(policy_ids, wait_time):
    '''
    Creates and standardizes the assignment levels information
    for triggered tasks to be inserted in the database.
    :param policy_ids: (list) of policy ids
    :param wait_time: time to wait for in each level
    :return: (list) of dict of the first level assignment
    '''
    levels = [
        {var_names.assignee_level: 1,
         var_names.level_minutes: wait_time,
         var_names.policy: policy_ids}
    ]
    return levels


def get_dict_value_from_dotted_key(dict_obj: dict, dotted_key: str):
    '''
    Gets the value of a key in a dict where sub-fields can be separated by a dot ('.').
    This function traverses the sub-path to find the correct value.
    :param dict_obj: (dict) the dict object to traverse
    :param dotted_key: (str) the dotted key; e.g - "payload.source.service"
    :return: the value of the key
    :errors: KeyError (if key is not found in the traverse pattern)
    '''
    keys = dotted_key.split('.')
    source = dict_obj
    i = 0
    while i < len(keys):
        try:
            source = source[keys[i]]
        except KeyError:
            # The function is fine as it is. The actual value can be None,
            # and so don't try to return None when error is raised.
            raise
        i += 1
    return source


def replace_internal_data_fields(msg, inst, lang):
    '''
    Replace param fields in a text (e.g. {{ urgency }}) with internal field values.
    :param msg: text message
    :param inst: (InstanceState) object
    :param lang: language to convert values to (needed for some fields like urgency level)
    :return: (str) updated text message
    '''
    try:
        matches = re.findall(r'({{\s*[a-zA-Z0-9_.]+\s*(?:\|\s*[^{]+)?}})', msg)
        if len(matches) == 0:
            return msg
        else:
            for item in matches:
                param_matches = re.findall(r'^{{\s*([a-zA-Z0-9_.]+)\s*(?:\|\s*([^{]+))?}}$', item)
                for prm_item in param_matches:
                    param = prm_item[0]
                    regex_match = None if string_validator.is_empty_string(prm_item[1]) else prm_item[1]

                    if param == var_names.global_incident_id:
                        msg = msg.replace(item, str(inst.instance_id))

                    elif param == var_names.incident_id:
                        msg = msg.replace(item, str(inst.organization_instance_id))

                    elif param == var_names.title:
                        if regex_match is None:
                            msg = msg.replace(item, inst.task.title())
                        else:
                            capture = re.findall(regex_match, inst.task.title())
                            if len(capture) > 0:
                                msg = msg.replace(item, capture[0])

                    elif param == var_names.description:
                        if regex_match is None:
                            msg = msg.replace(item, inst.task.text_msg())
                        else:
                            capture = re.findall(regex_match, inst.task.text_msg())
                            if len(capture) > 0:
                                msg = msg.replace(item, capture[0])

                    elif param == var_names.notes:
                        last_note = inst.get_last_note()
                        msg = msg.replace(item, '' if last_note is None else last_note)

                    elif param == var_names.status:
                        msg = msg.replace(item, inst.status)

                    elif param == var_names.urgency_level:
                        msg = msg.replace(item, get_urgency_map(inst.task.urgency_level(), lang))

                    elif param == var_names.from_number:
                        if inst.task.details[var_names.source_payload][var_names.trigger_method] ==\
                            constants.live_call_routing and\
                                'From' in inst.task.details[var_names.source_payload][var_names.source_payload]:
                            caller_num = inst.task.details[var_names.source_payload][var_names.source_payload]['From']
                            caller_num = caller_num[0] if isinstance(caller_num, list) else caller_num
                            msg = msg.replace(item, caller_num)

            return msg
    except Exception:
        logging.error('Failed to replace internal fields. Possibly invalid regex received.')
        return msg


def replace_response_data_fields(msg, action_outcomes):
    '''
    Replace param fields in a text (e.g. {{ step["slack"].field["channel_link"] }}) with custom action outcome values.
    :param msg: text message
    :param action_outcomes: (list) of action outcomes
    :return: (str) updated text message
    '''
    matches = re.findall(r'({{\s*step\[\"[a-zA-Z0-9-_.\s]+"].field\[\"[a-zA-Z0-9-_.]+"]\s*(?:\|\s*[^{]+)?}})', msg)
    if len(matches) == 0:
        return msg
    else:
        for item in matches:
            param_matches = re.findall(
                r'{{\s*step\[\"([a-zA-Z0-9-_.\s]+)"].field\[\"([a-zA-Z0-9-_.]+)"]\s*(?:\|\s*([^{]+))?}}', item
            )
            for prm_item in param_matches:
                resp_type_param = prm_item[0].split('-')
                resp_type = '-'.join(resp_type_param[:-1]) if len(resp_type_param) > 1 else resp_type_param[0]
                resp_index = int(resp_type_param[-1])\
                    if len(resp_type_param) > 1 and re.match('^[0-9]+$', resp_type_param[-1]) else -1
                param = prm_item[1]
                regex_match = None if string_validator.is_empty_string(prm_item[2]) else prm_item[2]

                filtered_act_out = [x for x in action_outcomes if x[0] == resp_type]
                if len(filtered_act_out) != 0 and len(filtered_act_out) > resp_index:
                    outcome = filtered_act_out[resp_index]
                    try:
                        resp_value = get_dict_value_from_dotted_key(outcome[2], param)
                        if regex_match is None:
                            msg = msg.replace(item, resp_value)
                        else:
                            capture = re.findall(regex_match, resp_value)
                            if len(capture) > 0:
                                msg = msg.replace(item, capture[0])
                    except (KeyError, TypeError):
                        logging.error('Could not find a match for the key - ' + param)
                    except Exception:
                        logging.error('Failed to replace response data fields. Possibly invalid regex received.')
        return msg


def extract_field_values_from_dict(msg, dict_obj):
    '''
    Replace param fields in a text (e.g. {{ urgency }}) with values from a dict object.
    :param msg: text message
    :param dict_obj: (dict) of data
    :return: (str) updated text message
    '''
    try:
        matches = re.findall(r'({{\s*[a-zA-Z0-9_.]+\s*(?:\|\s*[^{]+)?}})', msg)
        if len(matches) == 0:
            return msg
        else:
            for item in matches:
                param_matches = re.findall(r'^{{\s*([a-zA-Z0-9_.]+)\s*(?:\|\s*([^{]+))?}}$', item)
                for prm_item in param_matches:
                    param = prm_item[0]
                    regex_match = None if string_validator.is_empty_string(prm_item[1]) else prm_item[1]

                    try:
                        extracted_value = get_dict_value_from_dotted_key(dict_obj, param)
                        if regex_match is None:
                            msg = msg.replace(item, str(extracted_value))
                        else:
                            capture = re.findall(regex_match, str(extracted_value))
                            if len(capture) > 0:
                                msg = msg.replace(item, capture[0])
                    except Exception:
                        continue
            return msg
    except Exception:
        logging.error('Failed to extract fields from dict object. Possibly invalid regex received.')
        return msg


def get_phone_code(iso_code):
    '''
    Get the phone code given the iso code.
    :param iso_code: 2 letter ISO code
    :return: (str) phone code
    '''
    return constants.all_country_codes[iso_code][1]


def get_country_name(iso_code):
    '''
    Gets the name of the country given the iso code.
    :param iso_code: 2 letter ISO code
    :return: (str) country
    '''
    return constants.all_country_codes[iso_code][0]


def jsonify_unserializable(obj):
    '''
    This function should be used when dumping dict to json. datetime.datetime objects are not json serializable.
    So, we have to correct them using this function along with any other data types that are not serializable.
    :param obj: any type of object
    :return: (object) -> if the object is a datetime.datetime object then the format is corrected;
        otherwise returns the object as it is
    '''
    if isinstance(obj, datetime.datetime):
        obj_iso = obj.isoformat()
        if len(obj_iso) > 25 and obj_iso[-1] == '0':
            obj_iso = obj_iso[:-1]
        return obj_iso
    elif isinstance(obj, datetime.date) or isinstance(obj, datetime.time):
        return obj.isoformat()
    elif isinstance(obj, uuid.UUID):
        return str(obj)
    else:
        return obj


def get_urgency_map(urgency, lang):
    '''
    Get the urgency map of an urgency specific to a language.
    :param urgency: (int) system classified urgency
    :param lang: language to get the urgency value in
    :return: (str) language specific urgency description
    '''
    if urgency == constants.minor_urgency:
        return _lt.get_label(info.opt_minor, lang)
    elif urgency == constants.low_urgency:
        return _lt.get_label(info.opt_low, lang)
    elif urgency == constants.medium_urgency:
        return _lt.get_label(info.opt_medium, lang)
    elif urgency == constants.high_urgency:
        return _lt.get_label(info.opt_high, lang)
    elif urgency == constants.critical_urgency:
        return _lt.get_label(info.opt_critical, lang)


def get_instance_status_map(status, lang):
    '''
    Get the status map of a status specific to a language.
    :param status: (str) the status of the instance
    :param lang: language to get the urgency value in
    :return: (str) language specific urgency description
    '''
    if status == constants.open_state:
        return _lt.get_label(lnm.sts_open, lang)
    elif status == constants.acknowledged_state:
        return _lt.get_label(lnm.sts_acknowledged, lang)
    elif status == constants.resolved_state:
        return _lt.get_label(lnm.sts_resolved, lang)
    else:
        return None


def business_day_period(start_period, end_period, overlook_period=None):
    '''
    Get the number of business days that have elapsed between two dates.
    :param start_period: the start date
    :param end_period: the end date
    :param overlook_period: (tuple) -> (start date, end date)
    :return: (int) number of business days that have elapsed
    '''
    count = 0
    abs_diff = (end_period - start_period).days
    for i in range(0, abs_diff + 1):
        check_date = start_period + datetime.timedelta(days=i)
        if check_date.weekday() in [5, 6] or\
                (overlook_period is not None and overlook_period[0] <= check_date < overlook_period[1]):
            count += 1
    return abs_diff - count


def internationalize_phone_number(phone):
    '''
    Internationalize a phone number by adding '+' infront of it.
    :param phone: phone number
    :return: internationalized phone number
    '''
    if phone[0] != '+':
        return '+' + phone
    return phone


def add_space_between_numbers(message):
    '''
    Adds space between numbers in a string. This is used for sending Twilio voice message so that the numbers
    are not read as whole numbers.
    :param message: the message to change
    :return: (string) new message
    '''
    return re.sub(r"(\d+?)", r" \1 ", message).strip()


def un_jsonify_status_page_details(details):
    '''
    Convert status page details in their standard dictionary format by undoing all changes that were made to jsonify it.
    :param details: details of the status page
    :return: (dict) of status page details
    '''
    details = json.loads(details)
    if var_names.incidents in details and details[var_names.incidents] is not None:
        for inc in details[var_names.incidents]:
            inc[var_names.instance_timestamp] = times.get_timestamp_from_string(inc[var_names.instance_timestamp])
            inc[var_names.last_update] = times.get_timestamp_from_string(inc[var_names.last_update])

            for upd in inc[var_names.status_update]:
                upd[var_names.event_timestamp] = times.get_timestamp_from_string(upd[var_names.event_timestamp])

    return details


def get_post_from_status_page_details(details, post_id):
    '''
    Get a single post from the status page details as retrieved for the current state.
    :param details: (dict) of details
    :param post_id: (int) ID of the post
    :return: (dict) of post details
    '''
    posts = details[var_names.posts]
    for item in posts:
        if item[var_names.post_id] == post_id:
            return item
    return None
