# Copyright 2016 Canonical Ltd
#
# 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 glob
import os
import time
import subprocess
import hmac
import hashlib
import base64

import charms_openstack.charm as openstack_charm
import charms_openstack.adapters as adapters
import charms.reactive as reactive
import charmhelpers.core.decorators as ch_decorators
import charmhelpers.core.hookenv as hookenv
import charmhelpers.core.host as host

from charmhelpers.contrib.network import ip as ch_ip


LEADERDB_SECRET_KEY = 'rndc_key'
LEADERDB_SYNC_SRC_KEY = 'sync_src'
LEADERDB_SYNC_TIME_KEY = 'sync_time'
CLUSTER_SYNC_KEY = 'sync_request'
WWW_DIR = '/var/www/html'
ZONE_DIR = '/var/cache/bind'


def install():
    """Use the singleton from the DesignateBindCharm to install the packages
    on the unit

    :returns: None
    """
    DesignateBindCharm.singleton.install()


def set_apparmor():
    """Use the singleton from the DesignateBindCharm to setup apparmor

    :returns: None
    """
    DesignateBindCharm.singleton.set_apparmor()


def init_rndckey():
    """Use the singleton from the DesignateBindCharm to initalise the rndc key
    if possible and not already done

    :returns: str or None. Secret if available, None if not.
    """
    return DesignateBindCharm.singleton.init_rndckey()


def get_rndc_secret():
    """Use the singleton from the DesignateBindCharm to retrieve the RNDC
    secret

    :returns: str or None. Secret if available, None if not.
    """
    return DesignateBindCharm.singleton.get_rndc_secret()


def get_rndc_algorithm():
    """Use the singleton from the DesignateBindCharm to retrieve the RNDC
    algorithm

    :returns: str or None. Algorithm if available, None if not.
    """
    return DesignateBindCharm.singleton.get_rndc_algorithm()


def get_sync_time():
    """Use the singleton from the DesignateBindCharm to retrieve the time of
    the published zone zync target

    :returns: str or None. Current sync target creation time if available, None
                           if not.
    """
    return DesignateBindCharm.singleton.get_sync_time()


def setup_sync():
    """Use the singleton from the DesignateBindCharm to create a zone sync
    target

    :returns: None
    """
    DesignateBindCharm.singleton.setup_sync()


def retrieve_zones(cluster_relation=None):
    """Use the singleton from the DesignateBindCharm to retrieve the zone
    information and install it

    :param hacluster: OpenstackHAPeers() interface class
    :returns: None
    """
    DesignateBindCharm.singleton.retrieve_zones(cluster_relation)


def request_sync(hacluster):
    """Use the singleton from the DesignateBindCharm to request the leader
    creates a sync target

    :param hacluster: OpenstackHAPeers() interface class
    :returns: None
    """
    DesignateBindCharm.singleton.request_sync(hacluster)


def process_requests(hacluster):
    """Use the singleton from the DesignateBindCharm setup a sync target if
    requested

    :param hacluster: OpenstackHAPeers() interface class
    :returns: None
    """
    DesignateBindCharm.singleton.process_requests(hacluster)


def render_all_configs(interfaces_list):
    """Use the singleton from the DesignateBindCharm to render configurations
    and restart services as needed

    :param interfaces_list: List of instances of interface classes.
    :returns: None
    """
    DesignateBindCharm.singleton.render_with_interfaces(interfaces_list)


def assess_status():
    """Just call the DesignateBindCharm.singleton.assess_status() command
    to update status on the unit.

    @returns: None
    """
    DesignateBindCharm.singleton.assess_status()


class DNSAdapter(adapters.OpenStackRelationAdapter):

    def __init__(self, relation):
        super(DNSAdapter, self).__init__(relation)

    @property
    def control_listen_ip(self):
        """IP local rndc service listens on

        :returns: str: IP local rndc listens on
        """
        return ch_ip.get_relation_ip('dns-backend')

    @property
    def control_ips(self):
        """Comma delimited list of rndc client IPs

        :returns: str: Comma delimited list of rndc client IPs
        """
        return ';'.join(self.relation.client_ips())

    @property
    def algorithm(self):
        """Algorithm used to encode rndc secret

        :returns: str: Algorithm used to encode rndc secret
        """
        return DesignateBindCharm.get_rndc_algorithm()

    @property
    def secret(self):
        """RNDC Secret

        :returns: str: rndc secret
        """
        return DesignateBindCharm.get_rndc_secret()


class BindAdapters(adapters.OpenStackRelationAdapters):
    """
    Adapters class for the DesignateBind charm.
    """
    relation_adapters = {
        'dns_backend': DNSAdapter,
    }

    def __init__(self, relations):
        super(BindAdapters, self).__init__(
            relations)


class DesignateBindCharm(openstack_charm.OpenStackCharm):

    name = 'designate_bind'
    packages = ['bind9', 'apache2']

    services = ['bind9']

    required_relations = ['dns-backend']

    restart_map = {
        '/etc/bind/named.conf.options': services,
        '/etc/bind/named.conf': services,
        '/etc/bind/rndc.key': services,
    }
    service_type = 'designate_bind'
    default_service = 'bind9'
    adapters_class = BindAdapters
    release = 'icehouse'
    required_relations = []

    group = 'bind'

    def __init__(self, release=None, **kwargs):
        super(DesignateBindCharm, self).__init__(release='icehouse', **kwargs)

    @staticmethod
    def get_rndc_algorithm():
        """Algorithm used to encode rndc secret

        :returns: str: Algorithm used to encode rndc secret
        """
        return 'hmac-md5'

    @staticmethod
    def get_rndc_secret():
        """rndc secret

        :returns: str: rndc secret
        """
        return hookenv.leader_get(attribute=LEADERDB_SECRET_KEY)

    @staticmethod
    def get_sync_src():
        """URL published zone file can be retrieved from

        :returns: str: URL published zone file can be retrieved from
        """
        return hookenv.leader_get(attribute=LEADERDB_SYNC_SRC_KEY)

    @staticmethod
    def get_sync_time():
        """Epoch seconds when published sync was created

        :returns: str: Epoch seconds when published sync was created
        """
        return hookenv.leader_get(attribute=LEADERDB_SYNC_TIME_KEY)

    def process_requests(self, hacluster):
        """Check for sync requests and respond

        This should only be called by an application leader.
        Check to see if a peer has requested a sync. If so check if the time
        the request was created is more recent that then published sync target.
        If so, setup a new sync target. When the target is setup the leader db
        is updated with the new sync request time and URL. this will trigger a
        leader-*changed hook on the requesting unit allowing that unit to pick
        up the new file.

        :param hacluster: OpenstackHAPeers() interface class
        :returns: None
        """
        hookenv.log('Processing sync requests', level=hookenv.DEBUG)
        sync_requests = hacluster.retrieve_remote(CLUSTER_SYNC_KEY)
        max_time = 0
        for req in sync_requests:
            if float(req) > max_time:
                max_time = float(req)
        hookenv.log('Newest sync request: {}'.format(max_time),
                    level=hookenv.DEBUG)
        if max_time > float(self.get_sync_time()):
            self.setup_sync()

    def set_sync_info(self, sync_time, sync_file):
        """Update leader DB with sync information

        :param sync_time: str Time sync was created in epoch seconds
        :param sync_file: str Local file containing zone information
        :returns: None
        """
        sync_info = {
            LEADERDB_SYNC_SRC_KEY: 'http://{}:80/zone-syncs/{}'.format(
                hookenv.unit_private_ip(), sync_file),
            LEADERDB_SYNC_TIME_KEY: sync_time,
        }
        hookenv.leader_set(sync_info)

    def generate_rndc_key(self):
        """Generate a RNDC key

        :returns: str Base64 encoded hmac-md5 digest
        """
        key = os.urandom(10)
        dig = hmac.new(key, msg=b'RNDC Secret', digestmod=hashlib.md5).digest()
        return base64.b64encode(dig).decode()

    def init_rndckey(self):
        """Create a RNDC key if needed

        Return the rndc key from the leader DB or if one is not present
        generate a new one.

        :returns: str: rndc key
        """
        secret = DesignateBindCharm.get_rndc_secret()
        hookenv.log('Retrieving secret', level=hookenv.DEBUG)
        if not secret:
            hookenv.log('Secret not found in leader db', level=hookenv.DEBUG)
            if hookenv.is_leader():
                hookenv.log('Creating new secret as leader',
                            level=hookenv.DEBUG)
                secret = self.generate_rndc_key()
                hookenv.leader_set({LEADERDB_SECRET_KEY: secret})
        return secret

    def create_zone_tarball(self, tarfile):
        """Create a tar ball of zone files

        :param tarfile: str Location of tar ball to be created.
        :returns: None
        """
        zone_files = []
        for re in ['juju*', 'slave*', '*nzf']:
            for _file in glob.glob('{}/{}'.format(ZONE_DIR, re)):
                zone_files.append(os.path.basename(_file))
        cmd = ['tar', 'zcvf', tarfile]
        cmd.extend(zone_files)
        subprocess.check_call(cmd, cwd=ZONE_DIR)

    def setup_sync_dir(self, sync_time):
        """Create directory to store zone sync files

        :returns: None
        """
        sync_dir = '{}/zone-syncs'.format(WWW_DIR, sync_time)
        try:
            os.mkdir(sync_dir, 0o755)
        except FileExistsError:
            os.chmod(sync_dir, 0o755)
        return sync_dir

    def create_sync_src_info_file(self):
        """Touch a file which indicates where this sync file came from

        :returns: None
        """
        unit_name = hookenv.local_unit().replace('/', '_')
        touch_file = '{}/juju-zone-src-{}'.format(ZONE_DIR, unit_name)
        open(touch_file, 'w+').close()

    def setup_sync(self):
        """Setup a sync target

        Stop bind and tar up zone files, and start bind. Then update leaderdb
        with details of new sync.

        :returns: None
        """
        hookenv.log('Setting up zone info for collection', level=hookenv.DEBUG)
        sync_time = str(time.time())
        sync_dir = self.setup_sync_dir(sync_time)
        self.create_sync_src_info_file()
        # FIXME Try freezing DNS rather than stopping bind
        self.service_control('stop', ['bind9'])
        tar_file = '{}/{}.tar.gz'.format(sync_dir, sync_time)
        self.create_zone_tarball(tar_file)
        self.service_control('start', ['bind9'])
        self.set_sync_info(sync_time, '{}.tar.gz'.format(sync_time))

    def service_control(self, cmd, services):
        """Control listed services

        :param cmd: str Action to take on service (stop, start, restart)
        :returns: None
        """

        cmds = {
            'stop': host.service_stop,
            'start': host.service_start,
            'restart': host.service_restart,
        }
        for service in services:
            cmds[cmd](service)

    def request_sync(self, hacluster):
        """Request peer sets up a sync target

        Send a request via the cluster relation asking for a sync target to be
        setup.

        :param hacluster: OpenstackHAPeers() interface class
        :returns: None
        """
        request_time = str(time.time())
        hacluster.send_all({CLUSTER_SYNC_KEY: request_time}, store_local=True)
        reactive.set_state('sync.request.sent')

    @ch_decorators.retry_on_exception(3, base_delay=2,
                                      exc_type=subprocess.CalledProcessError)
    def wget_file(self, url, target_dir):
        """Retireve file from url into target_dir

        :param url: str Retrieve file from this url
        :param target_dir: Place file in this directory
        :returns: None
        """
        print("{} {}".format(url, target_dir))
        cmd = ['wget', url, '--retry-connrefused', '-t', '10']
        subprocess.check_call(cmd, cwd=target_dir)

    def retrieve_zones(self, cluster_relation=None):
        """Retrieve and install zones file

        Check if published sync target was created after this units sync
        request was sent, if it was install the zones file. Alternatively if
        no peer relation was set then assume the current sync target is to be
        used regardless of when it was created.

        :param cluster_relation: OpenstackHAPeers() interface class
        :returns: None
        """

        request_time = None
        if cluster_relation:
            request_times = list(set(
                cluster_relation.retrieve_local(CLUSTER_SYNC_KEY)))
            request_time = request_times[0]
        sync_time = DesignateBindCharm.get_sync_time()
        if request_time and sync_time and request_time > sync_time:
            hookenv.log(('Request for sync sent but remote sync time is too'
                         ' old, defering until a more up-to-date target is '
                         'available'),
                        level=hookenv.WARNING)
        else:
            url = DesignateBindCharm.get_sync_src()
            if url:
                self.service_control('stop', ['bind9'])
                self.wget_file(url, ZONE_DIR)
                tar_file = url.split('/')[-1]
                subprocess.check_call(['tar', 'xf', tar_file], cwd=ZONE_DIR)
                os.remove('{}/{}'.format(ZONE_DIR, tar_file))
                self.service_control('start', ['bind9'])
                reactive.remove_state('sync.request.sent')
                reactive.set_state('zones.initialised')
            else:
                hookenv.log(('Leader has not set valid url for zone download '
                             ' defering until leader provides url'),
                            level=hookenv.WARNING)

    def set_apparmor(self):
        """Disbale apparmor for named

        This is currently specified in the designate documentation
        http://docs.openstack.org/developer/designate/getting-started.html

        TODO: Check this is *really* needed

        :returns: None
        """
        apparmor_file = '/etc/apparmor.d/disable/usr.sbin.named'
        if not os.path.isfile(apparmor_file):
            open(apparmor_file, 'w').close()
            host.service_reload('apparmor')
