# Copyright 2014-2015 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.

import os
import yaml

from charmhelpers.core import hookenv
from charmhelpers.core import host
from charmhelpers.core import templating

from charmhelpers.core.services.base import ManagerCallback


__all__ = ['RelationContext', 'TemplateCallback',
           'render_template', 'template']


class RelationContext(dict):
    """
    Base class for a context generator that gets relation data from juju.

    Subclasses must provide the attributes `name`, which is the name of the
    interface of interest, `interface`, which is the type of the interface of
    interest, and `required_keys`, which is the set of keys required for the
    relation to be considered complete.  The data for all interfaces matching
    the `name` attribute that are complete will used to populate the dictionary
    values (see `get_data`, below).

    The generated context will be namespaced under the relation :attr:`name`,
    to prevent potential naming conflicts.

    :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
    :param list additional_required_keys: Extend the list of :attr:`required_keys`
    """
    name = None
    interface = None

    def __init__(self, name=None, additional_required_keys=None):
        if not hasattr(self, 'required_keys'):
            self.required_keys = []

        if name is not None:
            self.name = name
        if additional_required_keys:
            self.required_keys.extend(additional_required_keys)
        self.get_data()

    def __bool__(self):
        """
        Returns True if all of the required_keys are available.
        """
        return self.is_ready()

    __nonzero__ = __bool__

    def __repr__(self):
        return super(RelationContext, self).__repr__()

    def is_ready(self):
        """
        Returns True if all of the `required_keys` are available from any units.
        """
        ready = len(self.get(self.name, [])) > 0
        if not ready:
            hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
        return ready

    def _is_ready(self, unit_data):
        """
        Helper method that tests a set of relation data and returns True if
        all of the `required_keys` are present.
        """
        return set(unit_data.keys()).issuperset(set(self.required_keys))

    def get_data(self):
        """
        Retrieve the relation data for each unit involved in a relation and,
        if complete, store it in a list under `self[self.name]`.  This
        is automatically called when the RelationContext is instantiated.

        The units are sorted lexographically first by the service ID, then by
        the unit ID.  Thus, if an interface has two other services, 'db:1'
        and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
        and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
        set of data, the relation data for the units will be stored in the
        order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.

        If you only care about a single unit on the relation, you can just
        access it as `{{ interface[0]['key'] }}`.  However, if you can at all
        support multiple units on a relation, you should iterate over the list,
        like::

            {% for unit in interface -%}
                {{ unit['key'] }}{% if not loop.last %},{% endif %}
            {%- endfor %}

        Note that since all sets of relation data from all related services and
        units are in a single list, if you need to know which service or unit a
        set of data came from, you'll need to extend this class to preserve
        that information.
        """
        if not hookenv.relation_ids(self.name):
            return

        ns = self.setdefault(self.name, [])
        for rid in sorted(hookenv.relation_ids(self.name)):
            for unit in sorted(hookenv.related_units(rid)):
                reldata = hookenv.relation_get(rid=rid, unit=unit)
                if self._is_ready(reldata):
                    ns.append(reldata)

    def provide_data(self):
        """
        Return data to be relation_set for this interface.
        """
        return {}


class MysqlRelation(RelationContext):
    """
    Relation context for the `mysql` interface.

    :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
    :param list additional_required_keys: Extend the list of :attr:`required_keys`
    """
    name = 'db'
    interface = 'mysql'

    def __init__(self, *args, **kwargs):
        self.required_keys = ['host', 'user', 'password', 'database']
        RelationContext.__init__(self, *args, **kwargs)


class HttpRelation(RelationContext):
    """
    Relation context for the `http` interface.

    :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
    :param list additional_required_keys: Extend the list of :attr:`required_keys`
    """
    name = 'website'
    interface = 'http'

    def __init__(self, *args, **kwargs):
        self.required_keys = ['host', 'port']
        RelationContext.__init__(self, *args, **kwargs)

    def provide_data(self):
        return {
            'host': hookenv.unit_get('private-address'),
            'port': 80,
        }


class RequiredConfig(dict):
    """
    Data context that loads config options with one or more mandatory options.

    Once the required options have been changed from their default values, all
    config options will be available, namespaced under `config` to prevent
    potential naming conflicts (for example, between a config option and a
    relation property).

    :param list *args: List of options that must be changed from their default values.
    """

    def __init__(self, *args):
        self.required_options = args
        self['config'] = hookenv.config()
        with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
            self.config = yaml.safe_load(fp).get('options', {})

    def __bool__(self):
        for option in self.required_options:
            if option not in self['config']:
                return False
            current_value = self['config'][option]
            default_value = self.config[option].get('default')
            if current_value == default_value:
                return False
            if current_value in (None, '') and default_value in (None, ''):
                return False
        return True

    def __nonzero__(self):
        return self.__bool__()


class StoredContext(dict):
    """
    A data context that always returns the data that it was first created with.

    This is useful to do a one-time generation of things like passwords, that
    will thereafter use the same value that was originally generated, instead
    of generating a new value each time it is run.
    """
    def __init__(self, file_name, config_data):
        """
        If the file exists, populate `self` with the data from the file.
        Otherwise, populate with the given data and persist it to the file.
        """
        if os.path.exists(file_name):
            self.update(self.read_context(file_name))
        else:
            self.store_context(file_name, config_data)
            self.update(config_data)

    def store_context(self, file_name, config_data):
        if not os.path.isabs(file_name):
            file_name = os.path.join(hookenv.charm_dir(), file_name)
        with open(file_name, 'w') as file_stream:
            os.fchmod(file_stream.fileno(), 0o600)
            yaml.dump(config_data, file_stream)

    def read_context(self, file_name):
        if not os.path.isabs(file_name):
            file_name = os.path.join(hookenv.charm_dir(), file_name)
        with open(file_name, 'r') as file_stream:
            data = yaml.safe_load(file_stream)
            if not data:
                raise OSError("%s is empty" % file_name)
            return data


class TemplateCallback(ManagerCallback):
    """
    Callback class that will render a Jinja2 template, for use as a ready
    action.

    :param str source: The template source file, relative to
        `$CHARM_DIR/templates`

    :param str target: The target to write the rendered template to (or None)
    :param str owner: The owner of the rendered file
    :param str group: The group of the rendered file
    :param int perms: The permissions of the rendered file
    :param partial on_change_action: functools partial to be executed when
                                     rendered file changes
    :param jinja2 loader template_loader: A jinja2 template loader

    :return str: The rendered template
    """
    def __init__(self, source, target,
                 owner='root', group='root', perms=0o444,
                 on_change_action=None, template_loader=None):
        self.source = source
        self.target = target
        self.owner = owner
        self.group = group
        self.perms = perms
        self.on_change_action = on_change_action
        self.template_loader = template_loader

    def __call__(self, manager, service_name, event_name):
        pre_checksum = ''
        if self.on_change_action and os.path.isfile(self.target):
            pre_checksum = host.file_hash(self.target)
        service = manager.get_service(service_name)
        context = {'ctx': {}}
        for ctx in service.get('required_data', []):
            context.update(ctx)
            context['ctx'].update(ctx)

        result = templating.render(self.source, self.target, context,
                                   self.owner, self.group, self.perms,
                                   template_loader=self.template_loader)
        if self.on_change_action:
            if pre_checksum == host.file_hash(self.target):
                hookenv.log(
                    'No change detected: {}'.format(self.target),
                    hookenv.DEBUG)
            else:
                self.on_change_action()

        return result


# Convenience aliases for templates
render_template = template = TemplateCallback
