"""
Created on Aug 16, 2016

@author: Gaurav Rastogi (grastogi@avinetworks.com)
"""

from __future__ import absolute_import, division, print_function
__metaclass__ = type

import os
import re
import logging
import sys
from copy import deepcopy
from ansible.module_utils.basic import env_fallback

try:
    from ansible_collections.community.network.plugins.module_utils.network.avi.avi_api import (
        ApiSession, ObjectNotFound, avi_sdk_syslog_logger, AviCredentials, HAS_AVI)
except ImportError:
    HAS_AVI = False


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()


def _check_type_string(x):
    """
    :param x:
    :return: True if it is of type string
    """
    if isinstance(x, str):
        return True
    if sys.version_info[0] < 3:
        try:
            return isinstance(x, unicode)
        except NameError:
            return False


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:
        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"]
    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) is not dict:
        return obj
    cleanup_keys = []
    for k, v in obj.items():
        if type(v) is 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) is 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


RE_REF_MATCH = re.compile(r'^/api/[\w/]+\?name\=[\w]+[^#<>]*$')
# if HTTP ref match then strip out the #name
HTTP_REF_MATCH = re.compile(r'https://[\w.0-9:-]+/api/.+')
HTTP_REF_W_NAME_MATCH = re.compile(r'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)
    if not (_check_type_string(x) and _check_type_string(y)):
        return False
    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()
    if isinstance(x, str) or isinstance(x, str):
        # 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) is 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) is 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) is 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:
                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


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 != 'cluster':
        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. This is used in case of api/user
    # The following code copies the username and password
    # from the obj_username and obj_password 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 '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
            else:
                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 != 'cluster':
            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}
                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)
        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(fallback=(env_fallback, ['AVI_CONTROLLER'])),
        username=dict(fallback=(env_fallback, ['AVI_USERNAME'])),
        password=dict(fallback=(env_fallback, ['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'),
        timeout=dict(default=300, type='int'),
        token=dict(default='', type='str', no_log=True),
        session_id=dict(default='', type='str', no_log=True),
        csrftoken=dict(default='', type='str', no_log=True)
    )

    return dict(
        controller=dict(fallback=(env_fallback, ['AVI_CONTROLLER'])),
        username=dict(fallback=(env_fallback, ['AVI_USERNAME'])),
        password=dict(fallback=(env_fallback, ['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'))
