# -*- coding: utf-8 -*-
'''
The Voxel Hardware Cloud Module
===============================

The voxel_hw cloud module is used to interact with the voxel devices API.

Example configuration:
```
voxel-test-config:
  provider: voxel_hw
  key: XXXXXXX
  secret: XXXXXX
  url: http://api.voxel.net/version/1.0
```

To test your configuration you could call test_echo from the command line:
```
salt-cloud -f test_echo voxel_hw myparam=myvalue
```
If all is well you should see myparam=myvalue being echoed back by the
voxel server.

More examples:
```
salt-cloud -f list_nodes voxel_hw
salt-cloud -a reboot myserver
salt-cloud -f list_metrics voxel_hw device_id=123
salt-cloud -f list_metrics voxel_hw name=myserver
```

:maintainer:    Oliver Drake <oliver@drake.ch>
:maturity:      new
:depends:       requests
:platform:      all
'''
import functools
import hashlib
import logging
from urllib import urlencode
import datetime

import requests
import salt
import salt.config as config
from salt.exceptions import SaltCloudException, SaltCloudSystemExit

log = logging.getLogger(__name__)
SPECIAL_KEYS = set((u'@attributes', u'#text'))


###############################################################
############ Start of salt-cloud API ##########################
###############################################################
def create(vm_=None, call=None):
    '''
    Not supported as these are physical machines
    '''
    raise SaltCloudException("create() is not supported by voxel_hw")


def destroy(name, call=None):
    '''
    Not supported as these are physical machines
    '''
    raise SaltCloudException("destroy() is not supported by voxel_hw")


def list_nodes(call=None):
    '''
    Returns a list of nodes
    '''
    nodes = {}
    devices = list_devices(call='function', kwargs=dict(verbosity='extended'))
    for device in devices:
        if device.get('type').get('value') != 'Server':
            continue
        nodes[device['label']] = _transform_to_node_def(device)
    return nodes


def list_nodes_full(location=None, call=None):
    '''
    Returns a list of nodes with extra voxel specific data
    '''
    nodes = {}
    devices = list_devices(call='function', kwargs=dict(verbosity='extended'))
    for device in devices:
        if device.get('type').get('value') != 'Server':
            continue
        data = device
        data.update(_transform_to_node_def(device))
        nodes[device['label']] = data
    return nodes


def list_nodes_select(call=None):
    '''
    Return a list of the VMs that are on the provider, with select fields
    '''
    return salt.utils.cloud.list_nodes_select(
        list_nodes_full('function'), __opts__['query.selection'], call,
    )


def show_instance(name=None, call=None, kwargs=None):
    '''
    Show information about a specific instance
    '''
    if not name and call == 'action':
        raise SaltCloudSystemExit(
            'The show_instance action requires a name.'
        )

    if call == 'function':
        name = kwargs.get('name', None) if kwargs else None

    if not name:
        raise SaltCloudSystemExit('name is required')

    return list_nodes_full().get(name)


def script(vm_):
    '''
    Return the script deployment object
    '''
    return salt.utils.cloud.os_script(
        config.get_cloud_config_value('script', vm_, __opts__),
        vm_,
        __opts__,
        salt.utils.cloud.salt_config_to_yaml(
            salt.utils.cloud.minion_config(__opts__, vm_)
        )
    )


def start(name, call=None):
    '''
    Start (power on) a physical node
    '''
    id = _get_node_id(name)
    log.info("Starting (powering up) node: name=%s, id=%s" % (name, id))
    power(call='action', kwargs=dict(device_id=id, power_action='on'))
    return {}


def stop(name, call=None):
    '''
    Stop (power off) a physical node
    '''
    id = _get_node_id(name)
    log.info("Stopping (powering down) node: name=%s, id=%s" % (name, id))
    power(call='action', kwargs=dict(device_id=id, power_action='off'))
    return {}


def reboot(name, call=None):
    '''
    Reboot (power cycle) a physical node
    '''
    id = _get_node_id(name)
    log.info("Rebooting (power cycling) node: name=%s, id=%s" % (name, id))
    power(call='action', kwargs=dict(device_id=id, power_action='reboot'))
    return {'Reboot': 'Complete'}


def _get_node_id(name):
    node = show_instance(name, call='action')
    if not node:
        raise SaltCloudSystemExit('No node found for name: ' + name)
    id = node.get('id')
    return id


def _get_private_ips(device):
    for ip in device['ipassignments']:
        if ip.get('type') == 'backend':
            yield ip.get('value')


def _get_public_ips(device):
    for ip in device['ipassignments']:
        if ip.get('type') == 'frontend':
            yield ip.get('value')


def _transform_to_node_def(device):
    return {
        u'id': device[u'id'],
        u'image': device[u'operating_system'][0][u'name'].get(u'value'),
        u'size': u'N/A',
        u'state': device[u'status'],
        u'private_ips': list(_get_private_ips(device)),
        u'public_ips': list(_get_public_ips(device))
    }
############## End of salt-cloud API ##########################


def requires(*parameters):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(call=None, kwargs=None):
            if not kwargs:
                kwargs = {}
            name = kwargs.pop('name', None)
            if name:
                kwargs['device_id'] = _get_node_id(name)
            for param in parameters:
                if param not in kwargs:
                    raise SaltCloudException("%s is required" % param)
            return func(call=call, kwargs=kwargs)
        return wrapper
    return decorator


def endpoint(endpoint, calltype):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(call=None, kwargs=None):
            for key in kwargs.keys() if kwargs else []:
                if key.startswith("__"):
                    del kwargs[key]
            if call != calltype:
                raise SaltCloudException("Call must be %s" % calltype)
            try:
                return func(_request(endpoint, **(kwargs if kwargs else {})))
            except Exception:
                log.exception("Exception calling %s" % endpoint)
                raise
        return wrapper
    return decorator


###############################################################
########################## Voxel API ##########################
###############################################################
@endpoint('voxel.test.echo', 'function')
def test_echo(result):
    '''
    Test connecting to, and authenticating with the API works. Any extra
    parameters are echoed back to us by the server. This function simply
    returns the entire response as is for debugging purposes.
    '''
    return result


@endpoint('voxel.devices.list', 'function')
def list_devices(result):
    '''
    Get a list of all devices.
    :param device_id: optional
    :param facility: optional, datacenter facility code
    :param verbosity: optional, one of compact, normal or extended
    :return: list of device dictionaries
    '''
    return result.get('devices')


@requires('device_id')
@endpoint('voxel.devices.monitors.list', 'function')
def list_monitors(result):
    '''
    Get a list of all monitors by device id.
    :param device_id: required
    :param monitor_id: optional
    :return: a list of dictionaries containing information about the
        requested monitors
    '''
    return result.get('monitors')


@requires('device_id')
@endpoint('voxel.devices.monitors.enable', 'action')
def enable_monitor(result):
    '''
    Enable monitor(s) on a device
    :param device_id: required
    :param monitor_id: optional
    '''


@requires('device_id')
@endpoint('voxel.devices.monitors.disable', 'action')
def disable_monitor(result):
    '''
    Disable monitor(s) on a device
    :param device_id: required
    :param monitor_id: optional
    :param disable_minutes: optional, if greater than zero then monitor will
        be enabled after the specified number of minutes has passed
    '''


@requires('device_id', 'monitor_type')
@endpoint('voxel.devices.monitors.create', 'action')
def create_monitor(result):
    '''
    Create a monitor on a device. See the API spec for specific optional
    parameters for each monitor type.
    :param device_id: required
    :param monitor_type: required, e.g. imap, smtp
    :param ip_address: required (for everything other than power)
    '''


@requires('device_id', 'monitor_id')
@endpoint('voxel.devices.monitors.delete', 'action')
def delete_monitor(result):
    '''
    Delete a monitor on a device
    :param device_id: required
    :param monitor_id: required
    '''


@requires('device_id', 'monitor_id', 'monitor_type')
@endpoint('voxel.devices.monitors.update', 'action')
def update_monitor(result):
    '''
    Update an existing monitor on a device. As with create_monitor, you'll
    need to see the API documentation for extra optional configuration
    parameters.
    :param device_id: required
    :param monitor_id: required
    :param monitor_type: required
    '''


@requires('device_id')
@endpoint('voxel.devices.metrics.list', 'function')
def list_metrics(result):
    '''
    Get a list of supported metrics for a device.
    :param device_id: required
    :return: a list of dicts containing supported metrics
    '''
    return result.get('metrics')


@requires('device_id', 'metric')
@endpoint('voxel.devices.metrics.read', 'function')
def read_metric(result):
    '''
    Read a specific metric. You may want to use some of the optional params
    to filter the result down.
    :param device_id: required
    :param metric: required, the string type of the metric e.g. 'cpu'
    :param start_time: optional, unix timestamp
    :param end_time: optional, unix timestamp
    :param step_size: optional, amount of time between datapoints.
        One of: Minute, Hour, Day, Week, Month, Year.
    :param steps: optional, the number of data points to return
    :param interface: optional, e.g. 'eth0'
    :param port: optional
    :return: requested metrics
    '''
    return result.get('metric')


@requires('device_id', 'power_action')
@endpoint('voxel.devices.power', 'action')
def power(result):
    '''
    Perform a power action on a physical device.

    .. note: This is not a safe reboot, be sure to shut down the operating
        system beforehand.

    :param device_id: required
    :param power_action: required, one of: on, off or reboot
    '''
################### End of voxel API ##########################


def _request(method_name, **params):
    '''
    Send a request (execute a method) against the voxel API
    :param params: extra GET parameters
    :returns dict: The json response
    :raises: SaltCloudException if the remote server indicates a failure
    '''
    params['method'] = method_name
    params['format'] = 'json_v2'
    params.setdefault('verbosity', 'normal')
    params.setdefault('key', _get_config('key'))
    params.setdefault('secret', _get_config('secret'))
    utcnow = datetime.datetime.utcnow().isoformat() + "+0000"
    params.setdefault('timestamp', utcnow)
    params['api_sig'] = _compute_hash(params)

    full_url = "%s?%s" % (_get_config('url'), urlencode(params))

    response = requests.get(full_url)
    log.info("Executed method: %s against hAPI, status code: %s",
             method_name, response.status_code)
    log.debug("Full url: %s", full_url)
    result = response.json()
    log.debug("Response: %s", result)
    result = _clean_up_dict(result)
    status = result.pop('stat')
    if status != 'ok':
        msg = 'bad status in response: ' + status
        if 'err' in result:
            msg += ", msg: " + result.get('err').get('msg')
        raise SaltCloudException(msg)
    if response.status_code is 200:
        return result
    raise Exception(
        "Got unexpected status code in voxel response: %s",
        response.status_code)


def _compute_hash(params):
    secret = params.pop("secret")
    keys = params.keys()
    keys.sort()
    md5 = hashlib.md5()
    md5.update(secret)
    for key in keys:
        if params[key]:
            if params[key] is not None:
                md5.update("%s%s" % (key, params[key]))
            else:
                md5.update(key)
    api_sig = md5.hexdigest()
    return api_sig


def _extract_attributes(jsonobj):
    '''
    The json API returns a special @attributes field for some json objects,
    this is a direct mapping from the xml api (xml tag attributes).
    This function pops that special field out and applies its sub fields
    to the parent object (thereby creating a more natural dictionary).
    '''
    attributes = jsonobj.pop(u'@attributes', {})
    common_keys = set(jsonobj.keys()) & set(attributes.keys())
    if len(common_keys) > 0:
        log.warn("these attributes will be ignored: %s" % common_keys)
    jsonobj.update(attributes)


def _extract_value(jsonobj):
    '''
    As with @attributes, #text represents the text between xml open and close
    tags.
    '''
    if u'#text' in jsonobj:
        value = jsonobj.pop(u'#text')
        jsonobj[u'value'] = value


def _clean_up_dict(d):
    '''
    Iterate over the keys + values of a json dict, remove special fields
    and collapse nasty xml lists into more natural structures.
    '''
    _extract_attributes(d)
    _extract_value(d)
    for key, value in d.iteritems():
        if type(value) is list:
            if len(value) is 1:
                # collapse special singleton lists
                obj = value[0]
                if set(obj.keys()).issubset(SPECIAL_KEYS):
                    d[key] = _clean_up_dict(obj)
                else:
                    d[key] = _clean_up_list(key, value)
            else:
                d[key] = _clean_up_list(key, value)
        elif type(value) is dict:
            d[key] = _clean_up_dict(value)
    return d


def _clean_up_list(key, value):
    '''
    As with dicts, clean up and collapse lists to make the structure easier
    to consume.
    '''
    cleaned = []
    if len(value) == 1:
        # Special case for xml lists
        single_key = key.rstrip('s')
        obj = value[0]
        if len(obj.keys()) == 1 and single_key == obj.keys()[0]:
            return _clean_up_list(single_key, obj.get(single_key))

    # clean up any normal json objects in the list:
    for obj in value:
        cleaned.append(_clean_up_dict(obj))
    return cleaned


def __virtual__():
    '''
    Check for required configuration
    '''
    return False if get_configured_provider() is False else True


def get_configured_provider():
    '''
    Return the first configured instance.
    '''
    return config.is_provider_configured(
        __opts__,
        __active_provider_name__ or 'voxel_hw',
        ('key', 'secret', 'url')
    )


def _get_config(name):
    vm_ = get_configured_provider()
    return config.get_cloud_config_value(
        name, vm_, __opts__, search_global=False)



