# Copyright 2015 Infoblox Inc.
# All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import functools
import re
import urllib
import requests
import six
import urllib3
from requests import exceptions as req_exc

try:
    import urlparse
except ImportError:
    import urllib.parse as urlparse

try:
    from oslo_log import log as logging
except ImportError:  # pragma: no cover
    import logging

try:
    from oslo_serialization import jsonutils
except ImportError:  # pragma: no cover
    import json as jsonutils

from infoblox_client import exceptions as ib_ex
from infoblox_client import utils

LOG = logging.getLogger(__name__)
CLOUD_WAPI_MAJOR_VERSION = 2


def reraise_neutron_exception(func):
    @functools.wraps(func)
    def callee(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except req_exc.Timeout as e:
            raise ib_ex.InfobloxTimeoutError(e)
        except req_exc.RequestException as e:
            raise ib_ex.InfobloxConnectionError(reason=e)

    return callee


class Connector(object):
    """Connector stands for interacting with Infoblox NIOS

    Defines methods for getting, creating, updating and
    removing objects from an Infoblox server instance.
    """

    DEFAULT_HEADER = {'Content-type': 'application/json'}
    DEFAULT_OPTIONS = {'ssl_verify': False,
                       'silent_ssl_warnings': False,
                       'http_request_timeout': 10,
                       'http_pool_connections': 10,
                       'http_pool_maxsize': 10,
                       'max_retries': 3,
                       'wapi_version': '2.7',
                       'max_results': None,
                       'log_api_calls_as_info': False,
                       'paging': False}

    def __init__(self, options):
        self._parse_options(options)
        self._configure_session()
        # urllib has different interface for py27 and py34
        try:
            self._urlencode = urllib.urlencode
            self._quote = urllib.quote
            self._urljoin = urlparse.urljoin
        except AttributeError:
            self._urlencode = urlparse.urlencode
            self._quote = urlparse.quote
            self._urljoin = urlparse.urljoin

    def _parse_options(self, options):
        """Copy needed options to self"""
        attributes = ('host', 'wapi_version', 'username', 'password',
                      'ssl_verify', 'http_request_timeout', 'max_retries',
                      'http_pool_connections', 'http_pool_maxsize',
                      'silent_ssl_warnings', 'log_api_calls_as_info',
                      'max_results', 'paging')
        for attr in attributes:
            if isinstance(options, dict) and attr in options:
                setattr(self, attr, options[attr])
            elif hasattr(options, attr):
                value = getattr(options, attr)
                setattr(self, attr, value)
            elif attr in self.DEFAULT_OPTIONS:
                setattr(self, attr, self.DEFAULT_OPTIONS[attr])
            else:
                msg = "WAPI config error. Option %s is not defined" % attr
                raise ib_ex.InfobloxConfigException(msg=msg)

        for attr in ('host', 'username', 'password'):
            if not getattr(self, attr):
                msg = "WAPI config error. Option %s can not be blank" % attr
                raise ib_ex.InfobloxConfigException(msg=msg)

        self.wapi_url = "https://%s/wapi/v%s/" % (self.host,
                                                  self.wapi_version)
        self.cloud_api_enabled = self.is_cloud_wapi(
            self.wapi_version)

    def _configure_session(self):
        self.session = requests.Session()
        adapter = requests.adapters.HTTPAdapter(
            pool_connections=self.http_pool_connections,
            pool_maxsize=self.http_pool_maxsize,
            max_retries=self.max_retries)
        self.session.mount('http://', adapter)
        self.session.mount('https://', adapter)
        self.session.auth = (self.username, self.password)
        self.session.verify = utils.try_value_to_bool(self.ssl_verify,
                                                      strict_mode=False)

        if self.silent_ssl_warnings:
            urllib3.disable_warnings()

    def _construct_url(self, relative_path, query_params=None,
                       extattrs=None, force_proxy=False):
        if query_params is None:
            query_params = {}
        if extattrs is None:
            extattrs = {}
        if force_proxy:
            query_params['_proxy_search'] = 'GM'

        if not relative_path or relative_path[0] == '/':
            raise ValueError('Path in request must be relative.')
        query = ''
        if query_params or extattrs:
            query = '?'

        if extattrs:
            attrs_queries = []
            for key, value in extattrs.items():
                param = "*%s" % key
                value = value['value']
                if isinstance(value, list):
                    for item in value:
                        attrs_queries.append(self._urlencode({param: item}))
                else:
                    attrs_queries.append(self._urlencode({param: value}))
            query += '&'.join(attrs_queries)
        if query_params:
            if len(query) > 1:
                query += '&'
            query += self._urlencode(query_params)

        base_url = self._urljoin(self.wapi_url,
                                 self._quote(relative_path))
        return base_url + query

    @staticmethod
    def _validate_obj_type_or_die(obj_type, obj_type_expected=True):
        if not obj_type:
            raise ValueError('NIOS object type cannot be empty.')
        if obj_type_expected and '/' in obj_type:
            raise ValueError('NIOS object type cannot contain slash.')

    @staticmethod
    def _validate_authorized(response):
        if response.status_code == requests.codes.UNAUTHORIZED:
            raise ib_ex.InfobloxBadWAPICredential(response='')

    @staticmethod
    def _build_query_params(payload=None, return_fields=None,
                            max_results=None, paging=False):
        if payload:
            query_params = payload
        else:
            query_params = dict()

        if return_fields:
            if 'default' in return_fields:
                return_fields.remove('default')
                query_params['_return_fields+'] = ','.join(return_fields)
            else:
                query_params['_return_fields'] = ','.join(return_fields)

        if max_results:
            query_params['_max_results'] = max_results

        if paging:
            query_params['_paging'] = 1
            query_params['_return_as_object'] = 1

        return query_params

    def _get_request_options(self, data=None):
        opts = dict(timeout=self.http_request_timeout,
                    headers=self.DEFAULT_HEADER,
                    verify=self.session.verify)
        if data:
            opts['data'] = jsonutils.dumps(data)
        return opts

    @staticmethod
    def _parse_reply(request):
        """Tries to parse reply from NIOS.

        Raises exception with content if reply is not in json format
        """
        try:
            return jsonutils.loads(request.content)
        except ValueError:
            raise ib_ex.InfobloxConnectionError(reason=request.content)

    def _log_request(self, type, url, opts):
        message = ("Sending %s request to %s with parameters %s",
                   type, url, opts)
        if self.log_api_calls_as_info:
            LOG.info(*message)
        else:
            LOG.debug(*message)

    @reraise_neutron_exception
    def get_object(self, obj_type, payload=None, return_fields=None,
                   extattrs=None, force_proxy=False, max_results=None,
                   paging=False):
        """Retrieve a list of Infoblox objects of type 'obj_type'

        Some get requests like 'ipv4address' should be always
        proxied to GM on Hellfire
        If request is cloud and proxy is not forced yet,
        then plan to do 2 request:
        - the first one is not proxied to GM
        - the second is proxied to GM

        Args:
            obj_type  (str): Infoblox object type, e.g. 'network',
                            'range', etc.
            payload (dict): Payload with data to send
            return_fields (list): List of fields to be returned
            extattrs      (dict): List of Extensible Attributes
            force_proxy   (bool): Set _proxy_search flag
                                  to process requests on GM
            max_results   (int): Maximum number of objects to be returned.
                If set to a negative number the appliance will return an error
                when the number of returned objects would exceed the setting.
                The default is -1000. If this is set to a positive number,
                the results will be truncated when necessary.
            paging    (bool): Enables paging to wapi calls if paging = True,
                it uses _max_results to set paging size of the wapi calls.
                If _max_results is negative it will take paging size as 1000.

        Returns:
            A list of the Infoblox objects requested
        Raises:
            InfobloxObjectNotFound
        """
        self._validate_obj_type_or_die(obj_type, obj_type_expected=False)

        # max_results passed to get_object has priority over
        # one defined as connector option
        if max_results is None and self.max_results:
            max_results = self.max_results

        if paging is False and self.paging:
            paging = self.paging

        query_params = self._build_query_params(payload=payload,
                                                return_fields=return_fields,
                                                max_results=max_results,
                                                paging=paging)
        # Clear proxy flag if wapi version is too old (non-cloud)
        proxy_flag = self.cloud_api_enabled and force_proxy
        ib_object = self._handle_get_object(obj_type, query_params, extattrs,
                                            proxy_flag)
        if ib_object:
            return ib_object

        # Do second get call with force_proxy if not done yet
        if self.cloud_api_enabled and not force_proxy:
            ib_object = self._handle_get_object(obj_type, query_params,
                                                extattrs, proxy_flag=True)
            if ib_object:
                return ib_object

        return None

    def _handle_get_object(self, obj_type, query_params, extattrs,
                           proxy_flag=False):
        if '_paging' in query_params:

            if not ('_max_results' in query_params):
                query_params['_max_results'] = 1000

            if query_params['_max_results'] < 0:
                # Since pagination is enabled with _max_results < 0,
                # set _max_results = 1000.
                query_params['_max_results'] = 1000

            result = []
            while True:

                url = self._construct_url(obj_type, query_params, extattrs,
                                          force_proxy=proxy_flag)
                resp = self._get_object(obj_type, url)
                if not resp:
                    return None
                if not ('next_page_id' in resp):
                    result.extend(resp['result'])
                    query_params.pop('_page_id', None)
                    return result
                else:
                    query_params['_page_id'] = resp['next_page_id']
                    result.extend(resp['result'])
        else:
            url = self._construct_url(obj_type, query_params, extattrs,
                                      force_proxy=proxy_flag)
            return self._get_object(obj_type, url)

    def _get_object(self, obj_type, url):
        opts = self._get_request_options()
        self._log_request('get', url, opts)
        if self.session.cookies:
            # the first 'get' or 'post' action will generate a cookie
            # after that, we don't need to re-authenticate
            self.session.auth = None
        r = self.session.get(url, **opts)

        self._validate_authorized(r)

        if r.status_code != requests.codes.ok:
            LOG.warning("Failed on object search with url %s: %s",
                        url, r.content)
            return None
        return self._parse_reply(r)

    @reraise_neutron_exception
    def create_object(self, obj_type, payload, return_fields=None):
        """Create an Infoblox object of type 'obj_type'

        Args:
            obj_type        (str): Infoblox object type,
                                  e.g. 'network', 'range', etc.
            payload       (dict): Payload with data to send
            return_fields (list): List of fields to be returned
        Returns:
            The object reference of the newly create object
        Raises:
            InfobloxException
        """
        self._validate_obj_type_or_die(obj_type)

        query_params = self._build_query_params(return_fields=return_fields)

        url = self._construct_url(obj_type, query_params)
        opts = self._get_request_options(data=payload)
        self._log_request('post', url, opts)
        if self.session.cookies:
            # the first 'get' or 'post' action will generate a cookie
            # after that, we don't need to re-authenticate
            self.session.auth = None
        r = self.session.post(url, **opts)

        self._validate_authorized(r)

        if r.status_code != requests.codes.CREATED:
            response = utils.safe_json_load(r.content)
            already_assigned = 'is assigned to another network view'
            if response and already_assigned in response.get('text'):
                exception = ib_ex.InfobloxMemberAlreadyAssigned
            else:
                exception = ib_ex.InfobloxCannotCreateObject
            raise exception(
                response=response,
                obj_type=obj_type,
                content=r.content,
                args=payload,
                code=r.status_code)

        return self._parse_reply(r)

    def _check_service_availability(self, operation, resp, ref):
        if resp.status_code == requests.codes.SERVICE_UNAVAILABLE:
            raise ib_ex.InfobloxGridTemporaryUnavailable(
                response=resp.content,
                operation=operation,
                ref=ref,
                content=resp.content,
                code=resp.status_code)

    @reraise_neutron_exception
    def call_func(self, func_name, ref, payload, return_fields=None):
        query_params = self._build_query_params(return_fields=return_fields)
        query_params['_function'] = func_name

        url = self._construct_url(ref, query_params)
        opts = self._get_request_options(data=payload)
        self._log_request('post', url, opts)
        r = self.session.post(url, **opts)

        self._validate_authorized(r)

        if r.status_code not in (requests.codes.CREATED,
                                 requests.codes.ok):
            self._check_service_availability('call_func', r, ref)

            raise ib_ex.InfobloxFuncException(
                response=jsonutils.loads(r.content),
                ref=ref,
                func_name=func_name,
                content=r.content,
                code=r.status_code)

        return self._parse_reply(r)

    @reraise_neutron_exception
    def update_object(self, ref, payload, return_fields=None):
        """Update an Infoblox object

        Args:
            ref      (str): Infoblox object reference
            payload (dict): Payload with data to send
        Returns:
            The object reference of the updated object
        Raises:
            InfobloxException
        """
        query_params = self._build_query_params(return_fields=return_fields)

        opts = self._get_request_options(data=payload)
        url = self._construct_url(ref, query_params)
        self._log_request('put', url, opts)
        r = self.session.put(url, **opts)

        self._validate_authorized(r)

        if r.status_code != requests.codes.ok:
            self._check_service_availability('update', r, ref)

            raise ib_ex.InfobloxCannotUpdateObject(
                response=jsonutils.loads(r.content),
                ref=ref,
                content=r.content,
                code=r.status_code)

        return self._parse_reply(r)

    @reraise_neutron_exception
    def delete_object(self, ref, delete_arguments=None):
        """Remove an Infoblox object

        Args:
            ref               (str): Object reference
            delete_arguments (dict): Extra delete arguments
        Returns:
            The object reference of the removed object
        Raises:
            InfobloxException
        """
        opts = self._get_request_options()
        if not isinstance(delete_arguments, dict):
            delete_arguments = {}
        url = self._construct_url(ref, query_params=delete_arguments)
        self._log_request('delete', url, opts)
        r = self.session.delete(url, **opts)

        self._validate_authorized(r)

        if r.status_code != requests.codes.ok:
            self._check_service_availability('delete', r, ref)

            raise ib_ex.InfobloxCannotDeleteObject(
                response=jsonutils.loads(r.content),
                ref=ref,
                content=r.content,
                code=r.status_code)

        return self._parse_reply(r)

    @staticmethod
    def is_cloud_wapi(wapi_version):
        """Validate that a WAPI semantic version is valid.

        Args:
            wapi_version (str): WAPI semantic version

        Returns:
            True if the major version is higher than a given threshold,
            False otherwise.

        Raises:
            ValueError if an invalid version is passed
        """
        valid = wapi_version and isinstance(wapi_version, six.string_types)
        if not valid:
            raise ValueError("Invalid argument was passed")
        version_match = re.search(r'(\d+)\.(\d+)', wapi_version)
        if version_match:
            if int(version_match.group(1)) >= CLOUD_WAPI_MAJOR_VERSION:
                return True
        return False