# Copyright 2021 VMware, Inc.
# SPDX-License-Identifier: Apache License 2.0

"""
Created on Aug 16, 2016

@author: Gaurav Rastogi (grastogi@avinetworks.com)
"""
import os
import re
import sys
import logging
from copy import deepcopy
from avi.sdk.avi_api import ApiSession, ObjectNotFound, avi_sdk_syslog_logger, \
    AviCredentials

if os.environ.get('AVI_LOG_HANDLER', '') != 'syslog':
    log = logging.getLogger(__name__)
else:
    # Ansible does not allow logging from the modules.
    log = avi_sdk_syslog_logger()


class AviCheckModeResponse(object):
    """
    Class to support ansible check mode.
    """

    def __init__(self, obj, status_code=200):
        self.obj = obj
        self.status_code = status_code

    def json(self):
        return self.obj


def ansible_return(module, rsp, changed, req=None, existing_obj=None,
                   api_context=None):
    """
    :param module: AnsibleModule
    :param rsp: ApiResponse from avi_api
    :param changed: boolean
    :param req: ApiRequest to avi_api
    :param existing_obj: object to be passed debug output
    :param api_context: api login context

    helper function to return the right ansible based on the error code and
    changed
    Returns: specific ansible module exit function
    """

    if rsp is not None and rsp.status_code > 299 and not \
            any(error in rsp.text for error in SKIP_DELETE_ERROR):
        return module.fail_json(
            msg='Error %d Msg %s req: %s api_context:%s ' % (
                rsp.status_code, rsp.text, req, api_context))
    api_creds = AviCredentials()
    api_creds.update_from_ansible_module(module)
    key = '%s:%s:%s' % (api_creds.controller, api_creds.username,
                        api_creds.port)
    disable_fact = module.params.get('avi_disable_session_cache_as_fact')

    fact_context = None
    if not disable_fact:
        fact_context = module.params.get('api_context', {})
        if fact_context:
            fact_context.update({key: api_context})
        else:
            fact_context = {key: api_context}

    obj_val = rsp.json() if rsp else existing_obj

    if (obj_val and module.params.get("obj_username", None) and
                "username" in obj_val):
        obj_val["obj_username"] = obj_val["username"]
    if (obj_val and module.params.get("obj_password", None) and
                "password" in obj_val):
        obj_val["obj_password"] = obj_val["password"]
    if (obj_val and module.params.get("obj_state", None) and
                "state" in obj_val):
        obj_val["obj_state"] = obj_val["state"]
    old_obj_val = existing_obj if changed and existing_obj else None
    api_context_val = api_context if disable_fact else None
    ansible_facts_val = dict(
        avi_api_context=fact_context) if not disable_fact else {}

    return module.exit_json(
        changed=changed, obj=obj_val, old_obj=old_obj_val,
        ansible_facts=ansible_facts_val, api_context=api_context_val)


def purge_optional_fields(obj, module):
    """
    It purges the optional arguments to be sent to the controller.
    :param obj: dictionary of the ansible object passed as argument.
    :param module: AnsibleModule
    return modified obj
    """
    purge_fields = []
    for param, spec in module.argument_spec.items():
        if not spec.get('required', False):
            if param not in obj:
                # these are ansible common items
                continue
            if obj[param] is None:
                purge_fields.append(param)
    log.debug('purging fields %s', purge_fields)
    for param in purge_fields:
        obj.pop(param, None)
    return obj


def cleanup_absent_fields(obj):
    """
    cleans up any field that is marked as state: absent. It needs to be removed
    from the object if it is present.
    :param obj:
    :return: Purged object
    """
    if type(obj) != dict:
        return obj
    cleanup_keys = []
    for k, v in obj.items():
        if type(v) == dict:
            if (('state' in v and v['state'] == 'absent') or
                    (v == "{'state': 'absent'}")):
                cleanup_keys.append(k)
            else:
                cleanup_absent_fields(v)
                if not v:
                    cleanup_keys.append(k)
        elif type(v) == list:
            new_list = []
            for elem in v:
                elem = cleanup_absent_fields(elem)
                if elem:
                    # remove the item from list
                    new_list.append(elem)
            if new_list:
                obj[k] = new_list
            else:
                cleanup_keys.append(k)
        elif isinstance(v, str) or isinstance(v, str):
            if v == "{'state': 'absent'}":
                cleanup_keys.append(k)
    for k in cleanup_keys:
        del obj[k]
    return obj

def get_unicode_type():
    if sys.version_info < (3, 3):
        return unicode
    else:
        return str

RE_REF_MATCH = re.compile('^/api/[\w/]+\?name\=[\w*]+[^#<>]*$')

# if HTTP ref match then strip out the #name
# HTTP_REF_MATCH = re.compile('https://[\w.0-9:-]+/api/[\w/\?.#&-]*$')
HTTP_REF_MATCH = re.compile('https://[\w.0-9:-]+/api/.+')
HTTP_REF_W_NAME_MATCH = re.compile('https://[\w.0-9:-]+/api/.*#.+')


def ref_n_str_cmp(x, y):
    """
    compares two references
    1. check for exact reference
    2. check for obj_type/uuid
    3. check for name

    if x is ref=name then extract uuid and name from y and use it.
    if x is http_ref then
        strip x and y
        compare them.

    if x and y are urls then match with split on #
    if x is a RE_REF_MATCH then extract name
    if y is a REF_MATCH then extract name
    :param x: first string
    :param y: second string from controller's object

    Returns
        True if they are equivalent else False
    """
    if type(y) in (int, float, bool, int, complex):
        y = str(y)
        x = str(x)
    unicode_type = get_unicode_type()
    if not ((isinstance(x, str) or isinstance(x, unicode_type)) and
            (isinstance(y, str) or isinstance(y, unicode_type))):
        return False
    if str(type(y)) == "<type 'unicode'>":
        y = y.encode('utf-8')
    if str(type(x)) == "<type 'unicode'>":
        x = x.encode('utf-8')
    y_uuid = y_name = str(y)
    x = str(x)
    if RE_REF_MATCH.match(x):
        x = x.split('name=')[1]
    elif HTTP_REF_MATCH.match(x):
        x = x.rsplit('#', 1)[0]
        y = y.rsplit('#', 1)[0]
    elif RE_REF_MATCH.match(y):
        y = y.split('name=')[1]

    if HTTP_REF_W_NAME_MATCH.match(y):
        path = y.split('api/', 1)[1]
        # Fetching name or uuid from path /xxxx_xx/xx/xx_x/uuid_or_name
        uuid_or_name = path.split('/')[-1]
        parts = uuid_or_name.rsplit('#', 1)
        y_uuid = parts[0]
        y_name = parts[1] if len(parts) > 1 else ''
        # is just string but y is a url so match either uuid or name
    result = (x in (y, y_name, y_uuid))
    if not result:
        log.debug('x: %s y: %s y_name %s y_uuid %s',
                  x, y, y_name, y_uuid)
    return result

def avi_obj_cmp(x, y, sensitive_fields=None):
    """
    compares whether x is fully contained in y. The comparision is different
    from a simple dictionary compare for following reasons
    1. Some fields could be references. The object in controller returns the
        full URL for those references. However, the ansible script would have
        it specified as /api/pool?name=blah. So, the reference fields need
        to match uuid, relative reference based on name and actual reference.

    2. Optional fields with defaults: In case there are optional fields with
        defaults then controller automatically fills it up. This would
        cause the comparison with Ansible object specification to always return
        changed.

    3. Optional fields without defaults: This is most tricky. The issue is
        how to specify deletion of such objects from ansible script. If the
        ansible playbook has object specified as Null then Avi controller will
        reject for non Message(dict) type fields. In addition, to deal with the
        defaults=null issue all the fields that are set with None are purged
        out before comparing with Avi controller's version

        So, the solution is to pass state: absent if any optional field needs
        to be deleted from the configuration. The script would return changed
        =true if it finds a key in the controller version and it is marked with
        state: absent in ansible playbook. Alternatively, it would return
        false if key is not present in the controller object. Before, doing
        put or post it would purge the fields that are marked state: absent.

    :param x: first string
    :param y: second string from controller's object
    :param sensitive_fields: sensitive fields to ignore for diff

    Returns:
        True if x is subset of y else False
    """
    if not sensitive_fields:
        sensitive_fields = set()

    unicode_type = get_unicode_type()
    if isinstance(x, str) or isinstance(x, unicode_type):
        # Special handling for strings as they can be references.
        return ref_n_str_cmp(x, y)
    if type(x) not in [list, dict]:
        # if it is not list or dict or string then simply compare the values
        return x == y
    if type(x) == list:
        # should compare each item in the list and that should match
        if len(x) != len(y):
            log.debug('x has %d items y has %d', len(x), len(y))
            return False
        for i in zip(x, y):
            if not avi_obj_cmp(i[0], i[1], sensitive_fields=sensitive_fields):
                # no need to continue
                return False

    if type(x) == dict:
        x.pop('_last_modified', None)
        x.pop('tenant', None)
        y.pop('_last_modified', None)
        x.pop('api_version', None)
        y.pop('api_verison', None)
        d_xks = [k for k in x.keys() if k in sensitive_fields]

        if d_xks:
            # if there is sensitive field then always return changed
            return False
        # pop the keys that are marked deleted but not present in y
        # return false if item is marked absent and is present in y
        d_x_absent_ks = []
        for k, v in x.items():
            if v is None:
                d_x_absent_ks.append(k)
                continue
            if isinstance(v, dict):
                if ('state' in v) and (v['state'] == 'absent'):
                    if type(y) == dict and k not in y:
                        d_x_absent_ks.append(k)
                    else:
                        return False
                elif not v:
                    d_x_absent_ks.append(k)
            elif isinstance(v, list) and not v and k not in y:
                    d_x_absent_ks.append(k)
            # Added condition to check key in dict.
            elif isinstance(v, str) or (k in y and isinstance(y[k], str)):
                # this is the case when ansible converts the dictionary into a
                # string.
                if v == "{'state': 'absent'}" and k not in y:
                    d_x_absent_ks.append(k)
                elif not v and k not in y:
                    # this is the case when x has set the value that qualifies
                    # as not but y does not have that value
                    d_x_absent_ks.append(k)
        for k in d_x_absent_ks:
            x.pop(k)
        x_keys = set(x.keys())
        y_keys = set(y.keys())
        if not x_keys.issubset(y_keys):
            # log.debug('x has %s and y has %s keys', len(x_keys), len(y_keys))
            return False
        for k, v in x.items():
            if k not in y:
                # log.debug('k %s is not in y %s', k, y)
                return False
            if not avi_obj_cmp(v, y[k], sensitive_fields=sensitive_fields):
                # log.debug('k %s v %s did not match in y %s', k, v, y[k])
                return False
    return True


POP_FIELDS = ['state', 'controller', 'username', 'password', 'api_version',
              'avi_credentials', 'avi_api_update_method', 'avi_api_patch_op',
              'api_context', 'tenant', 'tenant_uuid', 'avi_disable_session_cache_as_fact']


def get_api_context(module, api_creds):
    api_context = module.params.get('api_context')
    if api_context and module.params.get('avi_disable_session_cache_as_fact'):
        return api_context
    elif api_context and not module.params.get(
            'avi_disable_session_cache_as_fact'):
        key = '%s:%s:%s' % (api_creds.controller, api_creds.username,
                            api_creds.port)
        return api_context.get(key)
    else:
        return None


NO_UUID_OBJ = ['cluster', 'systemconfiguration']
SKIP_DELETE_ERROR = ["Cannot delete system default object", "Method \'DELETE\' not allowed"]

def avi_ansible_api(module, obj_type, sensitive_fields):
    """
    This converts the Ansible module into AVI object and invokes APIs
    :param module: Ansible module
    :param obj_type: string representing Avi object type
    :param sensitive_fields: sensitive fields to be excluded for comparison
        purposes.
    Returns:
        success: module.exit_json with obj=avi object
        faliure: module.fail_json
    """
    api_creds = AviCredentials()
    api_creds.update_from_ansible_module(module)
    api_context = get_api_context(module, api_creds)
    if api_context:
        api = ApiSession.get_session(
            api_creds.controller,
            api_creds.username,
            password=api_creds.password,
            timeout=api_creds.timeout,
            tenant=api_creds.tenant,
            tenant_uuid=api_creds.tenant_uuid,
            token=api_context['csrftoken'],
            port=api_creds.port,
            session_id=api_context['session_id'],
            csrftoken=api_context['csrftoken'])
    else:
        api = ApiSession.get_session(
            api_creds.controller,
            api_creds.username,
            password=api_creds.password,
            timeout=api_creds.timeout,
            tenant=api_creds.tenant,
            tenant_uuid=api_creds.tenant_uuid,
            token=api_creds.token,
            port=api_creds.port,)
    state = module.params['state']
    # Get the api version.
    avi_update_method = module.params.get('avi_api_update_method', 'put')
    avi_patch_op = module.params.get('avi_api_patch_op', 'add')

    api_version = api_creds.api_version
    name = module.params.get('name', None)
    # Added Support to get uuid
    uuid = module.params.get('uuid', None)
    check_mode = module.check_mode
    if uuid and obj_type not in NO_UUID_OBJ:
        obj_path = '%s/%s' % (obj_type, uuid)
    else:
        obj_path = '%s/' % obj_type
    obj = deepcopy(module.params)
    tenant = obj.pop('tenant', '')
    tenant_uuid = obj.pop('tenant_uuid', '')
    # obj.pop('cloud_ref', None)
    for k in POP_FIELDS:
        obj.pop(k, None)
    purge_optional_fields(obj, module)

    # Special code to handle situation where object has a field
    # named username/password/state. The following code copies
    # username, password and state from the obj_username, obj_password
    # and obj_state fields.
    if 'obj_username' in obj:
        obj['username'] = obj['obj_username']
        obj.pop('obj_username')
    if 'obj_password' in obj:
        obj['password'] = obj['obj_password']
        obj.pop('obj_password')
    if 'obj_state' in obj:
        obj['state'] = obj['obj_state']
        obj.pop('obj_state')
    if 'full_name' not in obj and 'name' in obj and obj_type == "user":
        obj['full_name'] = obj['name']
        # Special case as name represent full_name in user module
        # As per API response, name is always same as username regardless of full_name
        obj['name'] = obj['username']

    log.info('passed object %s ', obj)

    if uuid:
        # Get the object based on uuid.
        try:
            existing_obj = api.get(
                obj_path, tenant=tenant, tenant_uuid=tenant_uuid,
                params={'include_refs': '', 'include_name': ''},
                api_version=api_version)
            existing_obj = existing_obj.json()
        except ObjectNotFound:
            existing_obj = None
    elif name:
        params = {'include_refs': '', 'include_name': ''}
        if obj.get('cloud_ref', None):
            # this is the case when gets have to be scoped with cloud
            cloud = obj['cloud_ref'].split('name=')[1]
            params['cloud_ref.name'] = cloud
        existing_obj = api.get_object_by_name(
            obj_type, name, tenant=tenant, tenant_uuid=tenant_uuid,
            params=params, api_version=api_version)

        # Need to check if tenant_ref was provided and the object returned
        # is actually in admin tenant.
        if existing_obj and 'tenant_ref' in obj and 'tenant_ref' in existing_obj:
            # https://10.10.25.42/api/tenant/admin#admin
            existing_obj_tenant = existing_obj['tenant_ref'].split('#')[1]
            obj_tenant = obj['tenant_ref'].split('name=')[1]
            if obj_tenant != existing_obj_tenant:
                existing_obj = None
    else:
        # added api version to avi api call.
        existing_obj = api.get(obj_path, tenant=tenant, tenant_uuid=tenant_uuid,
                               params={'include_refs': '', 'include_name': ''},
                               api_version=api_version).json()

    if state == 'absent':
        rsp = None
        changed = False
        err = False
        if not check_mode and existing_obj:
            try:
                if name is not None:
                    # added api version to avi api call.
                    rsp = api.delete_by_name(
                        obj_type, name, tenant=tenant, tenant_uuid=tenant_uuid,
                        api_version=api_version)
                else:
                    # added api version to avi api call.
                    rsp = api.delete(
                        obj_path, tenant=tenant, tenant_uuid=tenant_uuid,
                        api_version=api_version)
            except ObjectNotFound:
                pass
        if check_mode and existing_obj:
            changed = True

        if rsp:
            if rsp.status_code == 204:
                changed = True
            elif not any(error in str(rsp.text) for error in SKIP_DELETE_ERROR):
                err = True

        if not err:
            return ansible_return(
                module, rsp, changed, existing_obj=existing_obj,
                api_context=api.get_context())
        elif rsp:
            return module.fail_json(msg=rsp.text)

    rsp = None
    req = None
    if existing_obj:
        # this is case of modify as object exists. should find out
        # if changed is true or not
        if name is not None and obj_type not in NO_UUID_OBJ:
            obj_uuid = existing_obj['uuid']
            obj_path = '%s/%s' % (obj_type, obj_uuid)
        if avi_update_method == 'put':
            changed = not avi_obj_cmp(obj, existing_obj, sensitive_fields)
            obj = cleanup_absent_fields(obj)
            if changed:
                req = obj
                if check_mode:
                    # No need to process any further.
                    rsp = AviCheckModeResponse(obj=existing_obj)
                else:
                    rsp = api.put(
                        obj_path, data=req, tenant=tenant,
                        tenant_uuid=tenant_uuid, api_version=api_version)
            elif check_mode:
                rsp = AviCheckModeResponse(obj=existing_obj)
        else:
            if check_mode:
                # No need to process any further.
                rsp = AviCheckModeResponse(obj=existing_obj)
                changed = True
            else:
                obj.pop('name', None)
                patch_data = {avi_patch_op: obj}
                try:
                    rsp = api.patch(
                        obj_path, data=patch_data, tenant=tenant,
                        tenant_uuid=tenant_uuid, api_version=api_version)
                    obj = rsp.json()
                    changed = not avi_obj_cmp(obj, existing_obj)
                except ObjectNotFound:
                    changed = False
                    if avi_patch_op == 'delete':
                        rsp = None
        if changed:
            log.debug('EXISTING OBJ %s', existing_obj)
            log.debug('NEW OBJ %s', obj)
    else:
        changed = True
        req = obj
        if check_mode:
            rsp = AviCheckModeResponse(obj=None)
        else:
            rsp = api.post(obj_type, data=obj, tenant=tenant,
                           tenant_uuid=tenant_uuid, api_version=api_version)
    return ansible_return(module, rsp, changed, req, existing_obj=existing_obj,
                          api_context=api.get_context())


def avi_common_argument_spec():
    """
    Returns common arguments for all Avi modules
    :return: dict
    """
    credentials_spec = dict(
        controller=dict(default=os.environ.get('AVI_CONTROLLER', '')),
        username=dict(default=os.environ.get('AVI_USERNAME', '')),
        password=dict(default=os.environ.get('AVI_PASSWORD', ''), no_log=True),
        api_version=dict(default='16.4.4', type='str'),
        tenant=dict(default='admin'),
        tenant_uuid=dict(default='', type='str'),
        port=dict(type='int'),
        token=dict(default='', type='str', no_log=True),
        timeout=dict(default=300, type='int'),
        session_id=dict(default='', type='str', no_log=True),
        csrftoken=dict(default='', type='str', no_log=True)
    )

    return dict(
        controller=dict(default=os.environ.get('AVI_CONTROLLER', '')),
        username=dict(default=os.environ.get('AVI_USERNAME', '')),
        password=dict(default=os.environ.get('AVI_PASSWORD', ''), no_log=True),
        tenant=dict(default='admin'),
        tenant_uuid=dict(default=''),
        api_version=dict(default='16.4.4', type='str'),
        avi_credentials=dict(default=None, type='dict',
                             options=credentials_spec),
        api_context=dict(type='dict'),
        avi_disable_session_cache_as_fact=dict(default=False, type='bool'))


