# Copyright (c) 2015 Mirantis 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 copy
import ipaddress

from oslo_log import log

from manila.common import constants
from manila.i18n import _
from manila import utils

LOG = log.getLogger(__name__)


def locked_access_rules_operation(operation):
    """Lock decorator for access rules operations.

    Takes a named lock prior to executing the operation. The lock is
    named with the ID of the share instance to which the access rule belongs.

    Intended use:
    If an database operation to retrieve or update access rules uses this
    decorator, it will block actions on all access rules of the share
    instance until the named lock is free. This is used to avoid race
    conditions while performing access rules updates on a given share instance.
    """

    def wrapped(*args, **kwargs):
        instance_id = kwargs.get('share_instance_id')

        @utils.synchronized(
            "locked_access_rules_operation_by_share_instance_%s" % instance_id,
            external=True)
        def locked_operation(*_args, **_kwargs):
            return operation(*_args, **_kwargs)

        return locked_operation(*args, **kwargs)

    return wrapped


class ShareInstanceAccessDatabaseMixin(object):

    @locked_access_rules_operation
    def get_and_update_share_instance_access_rules_status(
            self, context, status=None, conditionally_change=None,
            share_instance_id=None):
        """Get and update the access_rules_status of a share instance.

        :param status: Set this parameter only if you want to
            omit the conditionally_change parameter; i.e, if you want to
            force a state change on the share instance regardless of the prior
            state.
        :param conditionally_change: Set this parameter to a dictionary of rule
            state transitions to be made. The key is the expected
            access_rules_status and the value is the state to transition the
            access_rules_status to. If the state is not as expected,
            no transition is performed. Default is {}, which means no state
            transitions will be made.
        :returns share_instance: if an update was made.
        """
        if status is not None:
            updates = {'access_rules_status': status}
        elif conditionally_change:
            share_instance = self.db.share_instance_get(
                context, share_instance_id)
            access_rules_status = share_instance['access_rules_status']
            try:
                updates = {
                    'access_rules_status':
                        conditionally_change[access_rules_status],
                }
            except KeyError:
                updates = {}
        else:
            updates = {}
        if updates:
            share_instance = self.db.share_instance_update(
                context, share_instance_id, updates, with_share_data=True)
            return share_instance

    def update_share_instances_access_rules_status(
            self, context, status, share_instance_ids):
        """Update the access_rules_status of all share instances.

        .. note::
            Before making this call, make sure that all share instances have
            their status set to a value that will block new operations to
            happen during this update.

        :param status: Force a state change on all share instances regardless
               of the prior state.
        :param share_instance_ids: List of share instance ids to have their
               access rules status updated.
        """
        updates = {'access_rules_status': status}

        self.db.share_instances_status_update(
            context, share_instance_ids, updates)

    @locked_access_rules_operation
    def get_and_update_share_instance_access_rules(self, context,
                                                   filters=None, updates=None,
                                                   conditionally_change=None,
                                                   share_instance_id=None):
        """Get and conditionally update all access rules of a share instance.

        :param updates: Set this parameter to a dictionary of key:value
            pairs corresponding to the keys in the ShareInstanceAccessMapping
            model. Include 'state' in this dictionary only if you want to
            omit the conditionally_change parameter; i.e, if you want to
            force a state change on all filtered rules regardless of the prior
            state. This parameter is always honored, regardless of whether
            conditionally_change allows for a state transition as desired.

            Example::

            {
                'access_key': 'bob007680048318f4239dfc1c192d5',
                'access_level': 'ro',
            }

        :param conditionally_change: Set this parameter to a dictionary of rule
            state transitions to be made. The key is the expected state of
            the access rule the value is the state to transition the
            access rule to. If the state is not as expected, no transition is
            performed. Default is {}, which means no state transitions
            will be made.

            Example::

            {
                'queued_to_apply': 'applying',
                'queued_to_deny': 'denying',
            }

        """
        instance_rules = self.db.share_access_get_all_for_instance(
            context, share_instance_id, filters=filters)

        if instance_rules and (updates or conditionally_change):
            if not updates:
                updates = {}
            if not conditionally_change:
                conditionally_change = {}
            for rule in instance_rules:
                mapping_state = rule['state']
                rule_updates = copy.deepcopy(updates)
                try:
                    rule_updates['state'] = conditionally_change[mapping_state]
                except KeyError:
                    pass
                if rule_updates:
                    self.db.share_instance_access_update(
                        context, rule['access_id'], share_instance_id,
                        rule_updates)

            # Refresh the rules after the updates
            rules_to_get = {
                'access_id': tuple([i['access_id'] for i in instance_rules]),
            }
            instance_rules = self.db.share_access_get_all_for_instance(
                context, share_instance_id, filters=rules_to_get)

        return instance_rules

    def get_share_instance_access_rules(self, context, filters=None,
                                        share_instance_id=None):
        return self.get_and_update_share_instance_access_rules(
            context, filters, None, None, share_instance_id)

    @locked_access_rules_operation
    def get_and_update_share_instance_access_rule(self, context, rule_id,
                                                  updates=None,
                                                  share_instance_id=None,
                                                  conditionally_change=None):
        """Get and conditionally update a given share instance access rule.

        :param updates: Set this parameter to a dictionary of key:value
            pairs corresponding to the keys in the ShareInstanceAccessMapping
            model. Include 'state' in this dictionary only if you want to
            omit the conditionally_change parameter; i.e, if you want to
            force a state change regardless of the prior state.
        :param conditionally_change: Set this parameter to a dictionary of rule
            state transitions to be made. The key is the expected state of
            the access rule the value is the state to transition the
            access rule to. If the state is not as expected, no transition is
            performed. Default is {}, which means no state transitions
            will be made.

            Example::

            {
                'queued_to_apply': 'applying',
                'queued_to_deny': 'denying',
            }
        """
        instance_rule_mapping = self.db.share_instance_access_get(
            context, rule_id, share_instance_id)

        if not updates:
            updates = {}
        if conditionally_change:
            mapping_state = instance_rule_mapping['state']
            try:
                updated_state = conditionally_change[mapping_state]
                updates.update({'state': updated_state})
            except KeyError:
                msg = ("The state of the access rule %(rule_id)s (allowing "
                       "access to share instance %(si)s) was not updated "
                       "because its state was modified by another operation.")
                msg_payload = {
                    'si': share_instance_id,
                    'rule_id': rule_id,
                }
                LOG.debug(msg, msg_payload)
        if updates:
            self.db.share_instance_access_update(
                context, rule_id, share_instance_id, updates)

            # Refresh the rule after update
            instance_rule_mapping = self.db.share_instance_access_get(
                context, rule_id, share_instance_id)

        return instance_rule_mapping

    @locked_access_rules_operation
    def delete_share_instance_access_rules(self, context, access_rules,
                                           share_instance_id=None):
        for rule in access_rules:
            self.db.share_instance_access_delete(context, rule['id'])


class ShareInstanceAccess(ShareInstanceAccessDatabaseMixin):

    def __init__(self, db, driver):
        self.db = db
        self.driver = driver

    def update_access_rules(self, context, share_instance_id,
                            delete_all_rules=False, share_server=None):
        """Update access rules for a given share instance.

        :param context: request context
        :param share_instance_id: ID of the share instance
        :param delete_all_rules: set this parameter to True if all
        existing access rules must be denied for a given share instance
        :param share_server: Share server model or None
        """
        share_instance = self.db.share_instance_get(
            context, share_instance_id, with_share_data=True)
        msg_payload = {
            'si': share_instance_id,
            'shr': share_instance['share_id'],
        }

        if delete_all_rules:
            updates = {
                'state': constants.ACCESS_STATE_QUEUED_TO_DENY,
            }
            self.get_and_update_share_instance_access_rules(
                context, updates=updates, share_instance_id=share_instance_id)

        # Is there a sync in progress? If yes, ignore the incoming request.
        rule_filter = {
            'state': (constants.ACCESS_STATE_APPLYING,
                      constants.ACCESS_STATE_DENYING),
        }
        syncing_rules = self.get_and_update_share_instance_access_rules(
            context, filters=rule_filter, share_instance_id=share_instance_id)

        if syncing_rules:
            msg = ("Access rules are being synced for share instance "
                   "%(si)s belonging to share %(shr)s, any rule changes will "
                   "be applied shortly.")
            LOG.debug(msg, msg_payload)
        else:
            rules_to_apply_or_deny = (
                self._update_and_get_unsynced_access_rules_from_db(
                    context, share_instance_id)
            )
            if rules_to_apply_or_deny:
                msg = ("Updating access rules for share instance %(si)s "
                       "belonging to share %(shr)s.")
                LOG.debug(msg, msg_payload)
                self._update_access_rules(context, share_instance_id,
                                          share_server=share_server)
            else:
                msg = ("All access rules have been synced for share instance "
                       "%(si)s belonging to share %(shr)s.")
                LOG.debug(msg, msg_payload)

    def _update_access_rules(self, context, share_instance_id,
                             share_server=None):
        # Refresh the share instance model
        share_instance = self.db.share_instance_get(
            context, share_instance_id, with_share_data=True)

        conditionally_change = {
            constants.STATUS_ACTIVE: constants.SHARE_INSTANCE_RULES_SYNCING,
        }
        share_instance = (
            self.get_and_update_share_instance_access_rules_status(
                context, conditionally_change=conditionally_change,
                share_instance_id=share_instance_id) or share_instance
        )

        rules_to_be_removed_from_db = []
        # Populate rules to send to the driver
        (access_rules_to_be_on_share, add_rules, delete_rules) = (
            self._get_rules_to_send_to_driver(context, share_instance)
        )

        if share_instance['cast_rules_to_readonly']:
            # Ensure read/only semantics for a migrating instances
            access_rules_to_be_on_share = self._set_rules_to_readonly(
                access_rules_to_be_on_share, share_instance)
            add_rules = []
            rules_to_be_removed_from_db = delete_rules
            delete_rules = []

        try:
            driver_rule_updates = self._update_rules_through_share_driver(
                context, share_instance, access_rules_to_be_on_share,
                add_rules, delete_rules, rules_to_be_removed_from_db,
                share_server)

            self.process_driver_rule_updates(
                context, driver_rule_updates, share_instance_id)

            # Update access rules that are still in 'applying' state
            conditionally_change = {
                constants.ACCESS_STATE_APPLYING: constants.ACCESS_STATE_ACTIVE,
            }
            self.get_and_update_share_instance_access_rules(
                context, share_instance_id=share_instance_id,
                conditionally_change=conditionally_change)

        except Exception:
            conditionally_change_rule_state = {
                constants.ACCESS_STATE_APPLYING: constants.ACCESS_STATE_ERROR,
                constants.ACCESS_STATE_DENYING: constants.ACCESS_STATE_ERROR,
            }
            self.get_and_update_share_instance_access_rules(
                context, share_instance_id=share_instance_id,
                conditionally_change=conditionally_change_rule_state)

            conditionally_change_access_rules_status = {
                constants.ACCESS_STATE_ACTIVE: constants.STATUS_ERROR,
                constants.SHARE_INSTANCE_RULES_SYNCING: constants.STATUS_ERROR,
            }
            self.get_and_update_share_instance_access_rules_status(
                context, share_instance_id=share_instance_id,
                conditionally_change=conditionally_change_access_rules_status)
            raise

        if rules_to_be_removed_from_db:
            delete_rules = rules_to_be_removed_from_db

        self.delete_share_instance_access_rules(
            context, delete_rules, share_instance_id=share_instance['id'])

        self._loop_for_refresh_else_update_access_rules_status(
            context, share_instance_id, share_server)

        msg = _("Access rules were successfully modified for share instance "
                "%(si)s belonging to share %(shr)s.")
        msg_payload = {
            'si': share_instance['id'],
            'shr': share_instance['share_id'],
        }
        LOG.info(msg, msg_payload)

    def _update_rules_through_share_driver(self, context, share_instance,
                                           access_rules_to_be_on_share,
                                           add_rules, delete_rules,
                                           rules_to_be_removed_from_db,
                                           share_server):
        driver_rule_updates = {}
        share_protocol = share_instance['share_proto'].lower()
        if (not self.driver.ipv6_implemented and
                share_protocol == 'nfs'):
            add_rules = self._filter_ipv6_rules(add_rules)
            delete_rules = self._filter_ipv6_rules(delete_rules)
            access_rules_to_be_on_share = self._filter_ipv6_rules(
                access_rules_to_be_on_share)
        try:
            driver_rule_updates = self.driver.update_access(
                context,
                share_instance,
                access_rules_to_be_on_share,
                add_rules=add_rules,
                delete_rules=delete_rules,
                share_server=share_server
            ) or {}
        except NotImplementedError:
            # NOTE(u_glide): Fallback to legacy allow_access/deny_access
            # for drivers without update_access() method support
            self._update_access_fallback(context, add_rules, delete_rules,
                                         rules_to_be_removed_from_db,
                                         share_instance,
                                         share_server)
        return driver_rule_updates

    def _loop_for_refresh_else_update_access_rules_status(self, context,
                                                          share_instance_id,
                                                          share_server):
        # Do we need to re-sync or apply any new changes?
        if self._check_needs_refresh(context, share_instance_id):
            self._update_access_rules(context, share_instance_id,
                                      share_server=share_server)
        else:
            # Switch the share instance's access_rules_status to 'active'
            # if there are no more rules in 'error' state, else, ensure
            # 'error' state.
            rule_filter = {'state': constants.STATUS_ERROR}
            rules_in_error_state = (
                self.get_and_update_share_instance_access_rules(
                    context, filters=rule_filter,
                    share_instance_id=share_instance_id)
            )
            if not rules_in_error_state:
                conditionally_change = {
                    constants.SHARE_INSTANCE_RULES_SYNCING:
                        constants.STATUS_ACTIVE,
                    constants.SHARE_INSTANCE_RULES_ERROR:
                        constants.STATUS_ACTIVE,
                }
                self.get_and_update_share_instance_access_rules_status(
                    context, conditionally_change=conditionally_change,
                    share_instance_id=share_instance_id)
            else:
                conditionally_change = {
                    constants.SHARE_INSTANCE_RULES_SYNCING:
                        constants.SHARE_INSTANCE_RULES_ERROR,
                }
                self.get_and_update_share_instance_access_rules_status(
                    context, conditionally_change=conditionally_change,
                    share_instance_id=share_instance_id)

    def process_driver_rule_updates(self, context, driver_rule_updates,
                                    share_instance_id):
        for rule_id, rule_updates in driver_rule_updates.items():
            if 'state' in rule_updates:
                # We allow updates *only* if the state is unchanged from
                # the time this update was initiated. It is possible
                # that the access rule was denied at the API prior to
                # the driver reporting that the access rule was added
                # successfully.
                state = rule_updates.pop('state')
                conditional_state_updates = {
                    constants.ACCESS_STATE_APPLYING: state,
                    constants.ACCESS_STATE_DENYING: state,
                    constants.ACCESS_STATE_ACTIVE: state,
                }
            else:
                conditional_state_updates = {}
            self.get_and_update_share_instance_access_rule(
                context, rule_id, updates=rule_updates,
                share_instance_id=share_instance_id,
                conditionally_change=conditional_state_updates)

    @staticmethod
    def _set_rules_to_readonly(access_rules_to_be_on_share, share_instance):

        LOG.debug("All access rules of share instance %s are being "
                  "cast to read-only for a migration or because the "
                  "instance is a readable replica.",
                  share_instance['id'])

        for rule in access_rules_to_be_on_share:
            rule['access_level'] = constants.ACCESS_LEVEL_RO

        return access_rules_to_be_on_share

    @staticmethod
    def _filter_ipv6_rules(rules):
        filtered = []
        for rule in rules:
            if rule['access_type'] == 'ip':
                ip_version = ipaddress.ip_network(
                    str(rule['access_to'])).version
                if 6 == ip_version:
                    continue
            filtered.append(rule)
        return filtered

    def _get_rules_to_send_to_driver(self, context, share_instance):
        add_rules = []
        delete_rules = []
        access_filters = {
            'state': (constants.ACCESS_STATE_APPLYING,
                      constants.ACCESS_STATE_ACTIVE,
                      constants.ACCESS_STATE_DENYING),
        }
        existing_rules_in_db = self.get_and_update_share_instance_access_rules(
            context, filters=access_filters,
            share_instance_id=share_instance['id'])
        # Update queued rules to transitional states
        for rule in existing_rules_in_db:

            if rule['state'] == constants.ACCESS_STATE_APPLYING:
                add_rules.append(rule)
            elif rule['state'] == constants.ACCESS_STATE_DENYING:
                delete_rules.append(rule)
        delete_rule_ids = [r['id'] for r in delete_rules]
        access_rules_to_be_on_share = [
            r for r in existing_rules_in_db if r['id'] not in delete_rule_ids
        ]
        return access_rules_to_be_on_share, add_rules, delete_rules

    def _check_needs_refresh(self, context, share_instance_id):
        rules_to_apply_or_deny = (
            self._update_and_get_unsynced_access_rules_from_db(
                context, share_instance_id)
        )
        return any(rules_to_apply_or_deny)

    def _update_access_fallback(self, context, add_rules, delete_rules,
                                remove_rules, share_instance, share_server):
        for rule in add_rules:
            LOG.info(
                "Applying access rule '%(rule)s' for share "
                "instance '%(instance)s'",
                {'rule': rule['id'], 'instance': share_instance['id']}
            )

            self.driver.allow_access(
                context,
                share_instance,
                rule,
                share_server=share_server
            )

        # NOTE(ganso): Fallback mode temporary compatibility workaround
        if remove_rules:
            delete_rules.extend(remove_rules)

        for rule in delete_rules:
            LOG.info(
                "Denying access rule '%(rule)s' from share "
                "instance '%(instance)s'",
                {'rule': rule['id'], 'instance': share_instance['id']}
            )

            self.driver.deny_access(
                context,
                share_instance,
                rule,
                share_server=share_server
            )

    def _update_and_get_unsynced_access_rules_from_db(self, context,
                                                      share_instance_id):
        rule_filter = {
            'state': (constants.ACCESS_STATE_QUEUED_TO_APPLY,
                      constants.ACCESS_STATE_QUEUED_TO_DENY),
        }
        conditionally_change = {
            constants.ACCESS_STATE_QUEUED_TO_APPLY:
                constants.ACCESS_STATE_APPLYING,
            constants.ACCESS_STATE_QUEUED_TO_DENY:
                constants.ACCESS_STATE_DENYING,
        }
        rules_to_apply_or_deny = (
            self.get_and_update_share_instance_access_rules(
                context, filters=rule_filter,
                share_instance_id=share_instance_id,
                conditionally_change=conditionally_change)
        )
        return rules_to_apply_or_deny

    def reset_rules_to_queueing_states(self, context, share_instance_id,
                                       reset_active=False):
        """Reset applying and denying rules to queued states.

        This helper is useful when re-applying rule changes.
        :param context: the RequestContext object
        :param share_instance_id: ID of the share instance
        :param reset_active: If True, set "active" rules to "queued_to_apply"
        """
        conditional_updates = {
            constants.ACCESS_STATE_APPLYING:
                constants.ACCESS_STATE_QUEUED_TO_APPLY,
            constants.ACCESS_STATE_DENYING:
                constants.ACCESS_STATE_QUEUED_TO_DENY,
        }
        if reset_active:
            conditional_updates.update({
                constants.STATUS_ACTIVE:
                    constants.ACCESS_STATE_QUEUED_TO_APPLY,
            })
        self.get_and_update_share_instance_access_rules_status(
            context,
            share_instance_id=share_instance_id,
            conditionally_change={
                constants.SHARE_INSTANCE_RULES_ERROR:
                    constants.SHARE_INSTANCE_RULES_SYNCING,
                constants.ACCESS_STATE_ACTIVE:
                    constants.SHARE_INSTANCE_RULES_SYNCING,
            },
        )
        self.get_and_update_share_instance_access_rules(
            context, share_instance_id=share_instance_id,
            conditionally_change=conditional_updates)
