# Copyright 2014-2016 Canonical Limited.
#
# 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.

#
# Copyright 2016 Canonical Ltd.
#
# Authors:
#  Openstack Charmers <
#

"""
Helpers for high availability.
"""

import hashlib
import json

import re

from charmhelpers.core.hookenv import (
    expected_related_units,
    log,
    relation_set,
    charm_name,
    config,
    status_set,
    DEBUG,
)

from charmhelpers.core.host import (
    lsb_release
)

from charmhelpers.contrib.openstack.ip import (
    resolve_address,
    is_ipv6,
)

from charmhelpers.contrib.network.ip import (
    get_iface_for_address,
    get_netmask_for_address,
)

from charmhelpers.contrib.hahelpers.cluster import (
    get_hacluster_config
)

JSON_ENCODE_OPTIONS = dict(
    sort_keys=True,
    allow_nan=False,
    indent=None,
    separators=(',', ':'),
)

VIP_GROUP_NAME = 'grp_{service}_vips'
DNSHA_GROUP_NAME = 'grp_{service}_hostnames'


class DNSHAException(Exception):
    """Raised when an error occurs setting up DNS HA
    """

    pass


def update_dns_ha_resource_params(resources, resource_params,
                                  relation_id=None,
                                  crm_ocf='ocf:maas:dns'):
    """ Configure DNS-HA resources based on provided configuration and
    update resource dictionaries for the HA relation.

    @param resources: Pointer to dictionary of resources.
                      Usually instantiated in ha_joined().
    @param resource_params: Pointer to dictionary of resource parameters.
                            Usually instantiated in ha_joined()
    @param relation_id: Relation ID of the ha relation
    @param crm_ocf: Corosync Open Cluster Framework resource agent to use for
                    DNS HA
    """
    _relation_data = {'resources': {}, 'resource_params': {}}
    update_hacluster_dns_ha(charm_name(),
                            _relation_data,
                            crm_ocf)
    resources.update(_relation_data['resources'])
    resource_params.update(_relation_data['resource_params'])
    relation_set(relation_id=relation_id, groups=_relation_data['groups'])


def assert_charm_supports_dns_ha():
    """Validate prerequisites for DNS HA
    The MAAS client is only available on Xenial or greater

    :raises DNSHAException: if release is < 16.04
    """
    if lsb_release().get('DISTRIB_RELEASE') < '16.04':
        msg = ('DNS HA is only supported on 16.04 and greater '
               'versions of Ubuntu.')
        status_set('blocked', msg)
        raise DNSHAException(msg)
    return True


def expect_ha():
    """ Determine if the unit expects to be in HA

    Check juju goal-state if ha relation is expected, check for VIP or dns-ha
    settings which indicate the unit should expect to be related to hacluster.

    @returns boolean
    """
    ha_related_units = []
    try:
        ha_related_units = list(expected_related_units(reltype='ha'))
    except (NotImplementedError, KeyError):
        pass
    return len(ha_related_units) > 0 or config('vip') or config('dns-ha')


def generate_ha_relation_data(service, extra_settings=None):
    """ Generate relation data for ha relation

    Based on configuration options and unit interfaces, generate a json
    encoded dict of relation data items for the hacluster relation,
    providing configuration for DNS HA or VIP's + haproxy clone sets.

    Example of supplying additional settings::

        COLO_CONSOLEAUTH = 'inf: res_nova_consoleauth grp_nova_vips'
        AGENT_CONSOLEAUTH = 'ocf:openstack:nova-consoleauth'
        AGENT_CA_PARAMS = 'op monitor interval="5s"'

        ha_console_settings = {
            'colocations': {'vip_consoleauth': COLO_CONSOLEAUTH},
            'init_services': {'res_nova_consoleauth': 'nova-consoleauth'},
            'resources': {'res_nova_consoleauth': AGENT_CONSOLEAUTH},
            'resource_params': {'res_nova_consoleauth': AGENT_CA_PARAMS})
        generate_ha_relation_data('nova', extra_settings=ha_console_settings)


    @param service: Name of the service being configured
    @param extra_settings: Dict of additional resource data
    @returns dict: json encoded data for use with relation_set
    """
    _haproxy_res = 'res_{}_haproxy'.format(service)
    _relation_data = {
        'resources': {
            _haproxy_res: 'lsb:haproxy',
        },
        'resource_params': {
            _haproxy_res: 'op monitor interval="5s"'
        },
        'init_services': {
            _haproxy_res: 'haproxy'
        },
        'clones': {
            'cl_{}_haproxy'.format(service): _haproxy_res
        },
    }

    if extra_settings:
        for k, v in extra_settings.items():
            if _relation_data.get(k):
                _relation_data[k].update(v)
            else:
                _relation_data[k] = v

    if config('dns-ha'):
        update_hacluster_dns_ha(service, _relation_data)
    else:
        update_hacluster_vip(service, _relation_data)

    return {
        'json_{}'.format(k): json.dumps(v, **JSON_ENCODE_OPTIONS)
        for k, v in _relation_data.items() if v
    }


def update_hacluster_dns_ha(service, relation_data,
                            crm_ocf='ocf:maas:dns'):
    """ Configure DNS-HA resources based on provided configuration

    @param service: Name of the service being configured
    @param relation_data: Pointer to dictionary of relation data.
    @param crm_ocf: Corosync Open Cluster Framework resource agent to use for
                    DNS HA
    """
    # Validate the charm environment for DNS HA
    assert_charm_supports_dns_ha()

    settings = ['os-admin-hostname', 'os-internal-hostname',
                'os-public-hostname', 'os-access-hostname']

    # Check which DNS settings are set and update dictionaries
    hostname_group = []
    for setting in settings:
        hostname = config(setting)
        if hostname is None:
            log('DNS HA: Hostname setting {} is None. Ignoring.'
                ''.format(setting),
                DEBUG)
            continue
        m = re.search('os-(.+?)-hostname', setting)
        if m:
            endpoint_type = m.group(1)
            # resolve_address's ADDRESS_MAP uses 'int' not 'internal'
            if endpoint_type == 'internal':
                endpoint_type = 'int'
        else:
            msg = ('Unexpected DNS hostname setting: {}. '
                   'Cannot determine endpoint_type name'
                   ''.format(setting))
            status_set('blocked', msg)
            raise DNSHAException(msg)

        hostname_key = 'res_{}_{}_hostname'.format(service, endpoint_type)
        if hostname_key in hostname_group:
            log('DNS HA: Resource {}: {} already exists in '
                'hostname group - skipping'.format(hostname_key, hostname),
                DEBUG)
            continue

        hostname_group.append(hostname_key)
        relation_data['resources'][hostname_key] = crm_ocf
        relation_data['resource_params'][hostname_key] = (
            'params fqdn="{}" ip_address="{}"'
            .format(hostname, resolve_address(endpoint_type=endpoint_type,
                                              override=False)))

    if len(hostname_group) >= 1:
        log('DNS HA: Hostname group is set with {} as members. '
            'Informing the ha relation'.format(' '.join(hostname_group)),
            DEBUG)
        relation_data['groups'] = {
            DNSHA_GROUP_NAME.format(service=service): ' '.join(hostname_group)
        }
    else:
        msg = 'DNS HA: Hostname group has no members.'
        status_set('blocked', msg)
        raise DNSHAException(msg)


def get_vip_settings(vip):
    """Calculate which nic is on the correct network for the given vip.

    If nic or netmask discovery fail then fallback to using charm supplied
    config. If fallback is used this is indicated via the fallback variable.

    @param vip: VIP to lookup nic and cidr for.
    @returns (str, str, bool): eg (iface, netmask, fallback)
    """
    iface = get_iface_for_address(vip)
    netmask = get_netmask_for_address(vip)
    fallback = False
    if iface is None:
        iface = config('vip_iface')
        fallback = True
    if netmask is None:
        netmask = config('vip_cidr')
        fallback = True
    return iface, netmask, fallback


def update_hacluster_vip(service, relation_data):
    """ Configure VIP resources based on provided configuration

    @param service: Name of the service being configured
    @param relation_data: Pointer to dictionary of relation data.
    """
    cluster_config = get_hacluster_config()
    vip_group = []
    vips_to_delete = []
    for vip in cluster_config['vip'].split():
        if is_ipv6(vip):
            res_vip = 'ocf:heartbeat:IPv6addr'
            vip_params = 'ipv6addr'
        else:
            res_vip = 'ocf:heartbeat:IPaddr2'
            vip_params = 'ip'

        iface, netmask, fallback = get_vip_settings(vip)

        vip_monitoring = 'op monitor depth="0" timeout="20s" interval="10s"'
        if iface is not None:
            # NOTE(jamespage): Delete old VIP resources
            # Old style naming encoding iface in name
            # does not work well in environments where
            # interface/subnet wiring is not consistent
            vip_key = 'res_{}_{}_vip'.format(service, iface)
            if vip_key in vips_to_delete:
                vip_key = '{}_{}'.format(vip_key, vip_params)
            vips_to_delete.append(vip_key)

            vip_key = 'res_{}_{}_vip'.format(
                service,
                hashlib.sha1(vip.encode('UTF-8')).hexdigest()[:7])

            relation_data['resources'][vip_key] = res_vip
            # NOTE(jamespage):
            # Use option provided vip params if these where used
            # instead of auto-detected values
            if fallback:
                relation_data['resource_params'][vip_key] = (
                    'params {ip}="{vip}" cidr_netmask="{netmask}" '
                    'nic="{iface}" {vip_monitoring}'.format(
                        ip=vip_params,
                        vip=vip,
                        iface=iface,
                        netmask=netmask,
                        vip_monitoring=vip_monitoring))
            else:
                # NOTE(jamespage):
                # let heartbeat figure out which interface and
                # netmask to configure, which works nicely
                # when network interface naming is not
                # consistent across units.
                relation_data['resource_params'][vip_key] = (
                    'params {ip}="{vip}" {vip_monitoring}'.format(
                        ip=vip_params,
                        vip=vip,
                        vip_monitoring=vip_monitoring))

            vip_group.append(vip_key)

    if vips_to_delete:
        try:
            relation_data['delete_resources'].extend(vips_to_delete)
        except KeyError:
            relation_data['delete_resources'] = vips_to_delete

    if len(vip_group) >= 1:
        key = VIP_GROUP_NAME.format(service=service)
        try:
            relation_data['groups'][key] = ' '.join(vip_group)
        except KeyError:
            relation_data['groups'] = {
                key: ' '.join(vip_group)
            }
