#    Copyright 2013 Red Hat, Inc
#
#    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.

from oslo_db import exception as db_exc
from oslo_db.sqlalchemy import utils as sqlalchemyutils
from oslo_utils import versionutils
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy import sql
from sqlalchemy.sql import expression

import nova.conf
from nova.db.api import api as api_db_api
from nova.db.api import models as api_models
from nova.db import utils as db_utils
from nova import exception
from nova.notifications.objects import base as notification
from nova.notifications.objects import flavor as flavor_notification
from nova import objects
from nova.objects import base
from nova.objects import fields


OPTIONAL_FIELDS = ['extra_specs', 'projects']
# Remove these fields in version 2.0 of the object.
DEPRECATED_FIELDS = ['deleted', 'deleted_at']

# Non-joined fields which can be updated.
MUTABLE_FIELDS = set(['description'])

CONF = nova.conf.CONF


def _dict_with_extra_specs(flavor_model):
    extra_specs = {x['key']: x['value']
                   for x in flavor_model['extra_specs']}
    return dict(flavor_model, extra_specs=extra_specs)


# NOTE(danms): There are some issues with the oslo_db context manager
# decorators with static methods. We pull these out for now and can
# move them back into the actual staticmethods on the object when those
# issues are resolved.
@api_db_api.context_manager.reader
def _get_projects_from_db(context, flavorid):
    db_flavor = context.session.query(api_models.Flavors).filter_by(
        flavorid=flavorid
    ).options(
        orm.joinedload(api_models.Flavors.projects)
    ).first()
    if not db_flavor:
        raise exception.FlavorNotFound(flavor_id=flavorid)
    return [x['project_id'] for x in db_flavor['projects']]


@api_db_api.context_manager.writer
def _flavor_add_project(context, flavor_id, project_id):
    project = api_models.FlavorProjects()
    project.update({'flavor_id': flavor_id,
                    'project_id': project_id})
    try:
        project.save(context.session)
    except db_exc.DBDuplicateEntry:
        raise exception.FlavorAccessExists(flavor_id=flavor_id,
                                           project_id=project_id)


@api_db_api.context_manager.writer
def _flavor_del_project(context, flavor_id, project_id):
    result = context.session.query(api_models.FlavorProjects).\
             filter_by(project_id=project_id).\
             filter_by(flavor_id=flavor_id).\
             delete()
    if result == 0:
        raise exception.FlavorAccessNotFound(flavor_id=flavor_id,
                                             project_id=project_id)


@api_db_api.context_manager.writer
def _flavor_extra_specs_add(context, flavor_id, specs, max_retries=10):
    writer = api_db_api.context_manager.writer
    for attempt in range(max_retries):
        try:
            spec_refs = context.session.query(
                api_models.FlavorExtraSpecs).\
                filter_by(flavor_id=flavor_id).\
                filter(api_models.FlavorExtraSpecs.key.in_(
                    specs.keys())).\
                all()

            existing_keys = set()
            for spec_ref in spec_refs:
                key = spec_ref["key"]
                existing_keys.add(key)
                with writer.savepoint.using(context):
                    spec_ref.update({"value": specs[key]})

            for key, value in specs.items():
                if key in existing_keys:
                    continue
                spec_ref = api_models.FlavorExtraSpecs()
                with writer.savepoint.using(context):
                    spec_ref.update({"key": key, "value": value,
                                     "flavor_id": flavor_id})
                    context.session.add(spec_ref)

            return specs
        except db_exc.DBDuplicateEntry:
            # a concurrent transaction has been committed,
            # try again unless this was the last attempt
            if attempt == max_retries - 1:
                raise exception.FlavorExtraSpecUpdateCreateFailed(
                    id=flavor_id, retries=max_retries)


@api_db_api.context_manager.writer
def _flavor_extra_specs_del(context, flavor_id, key):
    result = context.session.query(api_models.FlavorExtraSpecs).\
             filter_by(flavor_id=flavor_id).\
             filter_by(key=key).\
             delete()
    if result == 0:
        raise exception.FlavorExtraSpecsNotFound(
            extra_specs_key=key, flavor_id=flavor_id)


@api_db_api.context_manager.writer
def _flavor_create(context, values):
    specs = values.get('extra_specs')
    db_specs = []
    if specs:
        for k, v in specs.items():
            db_spec = api_models.FlavorExtraSpecs()
            db_spec['key'] = k
            db_spec['value'] = v
            db_specs.append(db_spec)

    projects = values.get('projects')
    db_projects = []
    if projects:
        for project in set(projects):
            db_project = api_models.FlavorProjects()
            db_project['project_id'] = project
            db_projects.append(db_project)

    values['extra_specs'] = db_specs
    values['projects'] = db_projects
    db_flavor = api_models.Flavors()
    db_flavor.update(values)

    try:
        db_flavor.save(context.session)
    except db_exc.DBDuplicateEntry as e:
        if 'flavorid' in e.columns:
            raise exception.FlavorIdExists(flavor_id=values['flavorid'])
        raise exception.FlavorExists(name=values['name'])
    except Exception as e:
        raise db_exc.DBError(e)

    return _dict_with_extra_specs(db_flavor)


@api_db_api.context_manager.writer
def _flavor_destroy(context, flavor_id=None, flavorid=None):
    query = context.session.query(api_models.Flavors)

    if flavor_id is not None:
        query = query.filter(api_models.Flavors.id == flavor_id)
    else:
        query = query.filter(api_models.Flavors.flavorid == flavorid)
    result = query.first()

    if not result:
        raise exception.FlavorNotFound(flavor_id=(flavor_id or flavorid))

    context.session.query(api_models.FlavorProjects).\
        filter_by(flavor_id=result.id).delete()
    context.session.query(api_models.FlavorExtraSpecs).\
        filter_by(flavor_id=result.id).delete()
    context.session.delete(result)
    return result


# TODO(berrange): Remove NovaObjectDictCompat
# TODO(mriedem): Remove NovaPersistentObject in version 2.0
@base.NovaObjectRegistry.register
class Flavor(base.NovaPersistentObject, base.NovaObject,
             base.NovaObjectDictCompat):
    # Version 1.0: Initial version
    # Version 1.1: Added save_projects(), save_extra_specs(), removed
    #              remotable from save()
    # Version 1.2: Added description field. Note: this field should not be
    #              persisted with the embedded instance.flavor.
    VERSION = '1.2'

    fields = {
        'id': fields.IntegerField(),
        'name': fields.StringField(nullable=True),
        'memory_mb': fields.IntegerField(),
        'vcpus': fields.IntegerField(),
        'root_gb': fields.IntegerField(),
        'ephemeral_gb': fields.IntegerField(),
        'flavorid': fields.StringField(),
        'swap': fields.IntegerField(),
        'rxtx_factor': fields.FloatField(nullable=True, default=1.0),
        'vcpu_weight': fields.IntegerField(nullable=True),
        'disabled': fields.BooleanField(),
        'is_public': fields.BooleanField(),
        'extra_specs': fields.DictOfStringsField(),
        'projects': fields.ListOfStringsField(),
        'description': fields.StringField(nullable=True)
        }

    def __init__(self, *args, **kwargs):
        super(Flavor, self).__init__(*args, **kwargs)
        self._orig_extra_specs = {}
        self._orig_projects = []

    def obj_make_compatible(self, primitive, target_version):
        super(Flavor, self).obj_make_compatible(primitive, target_version)
        target_version = versionutils.convert_version_to_tuple(target_version)
        if target_version < (1, 2) and 'description' in primitive:
            del primitive['description']

    @staticmethod
    def _from_db_object(context, flavor, db_flavor, expected_attrs=None):
        if expected_attrs is None:
            expected_attrs = []
        flavor._context = context
        for name, field in flavor.fields.items():
            if name in OPTIONAL_FIELDS:
                continue
            if name in DEPRECATED_FIELDS and name not in db_flavor:
                continue
            value = db_flavor[name]
            if isinstance(field, fields.IntegerField):
                value = value if value is not None else 0
            flavor[name] = value

        # NOTE(danms): This is to support processing the API flavor
        # model, which does not have these deprecated fields. When we
        # remove compatibility with the old InstanceType model, we can
        # remove this as well.
        if any(f not in db_flavor for f in DEPRECATED_FIELDS):
            flavor.deleted_at = None
            flavor.deleted = False

        if 'extra_specs' in expected_attrs:
            flavor.extra_specs = db_flavor['extra_specs']

        if 'projects' in expected_attrs:
            if 'projects' in db_flavor:
                flavor['projects'] = [x['project_id']
                                      for x in db_flavor['projects']]
            else:
                flavor._load_projects()

        flavor.obj_reset_changes()
        return flavor

    @staticmethod
    def _flavor_get_query_from_db(context):
        # We don't use a database context decorator on this method because this
        # method is not executing a query, it's only building one.
        query = context.session.query(api_models.Flavors).options(
            orm.joinedload(api_models.Flavors.extra_specs)
        )
        if not context.is_admin:
            the_filter = [api_models.Flavors.is_public == sql.true()]
            the_filter.extend([
                api_models.Flavors.projects.any(project_id=context.project_id)
            ])
            query = query.filter(sa.or_(*the_filter))
        return query

    @staticmethod
    @db_utils.require_context
    @api_db_api.context_manager.reader
    def _flavor_get_from_db(context, id):
        """Returns a dict describing specific flavor."""
        result = Flavor._flavor_get_query_from_db(context).\
                        filter_by(id=id).\
                        first()
        if not result:
            raise exception.FlavorNotFound(flavor_id=id)
        return _dict_with_extra_specs(result)

    @staticmethod
    @db_utils.require_context
    @api_db_api.context_manager.reader
    def _flavor_get_by_name_from_db(context, name):
        """Returns a dict describing specific flavor."""
        result = Flavor._flavor_get_query_from_db(context).\
                            filter_by(name=name).\
                            first()
        if not result:
            raise exception.FlavorNotFoundByName(flavor_name=name)
        return _dict_with_extra_specs(result)

    @staticmethod
    @db_utils.require_context
    @api_db_api.context_manager.reader
    def _flavor_get_by_flavor_id_from_db(context, flavor_id):
        """Returns a dict describing specific flavor_id."""
        result = Flavor._flavor_get_query_from_db(context).\
                        filter_by(flavorid=flavor_id).\
                        order_by(expression.asc(api_models.Flavors.id)).\
                        first()
        if not result:
            raise exception.FlavorNotFound(flavor_id=flavor_id)
        return _dict_with_extra_specs(result)

    @staticmethod
    def _get_projects_from_db(context, flavorid):
        return _get_projects_from_db(context, flavorid)

    @base.remotable
    def _load_projects(self):
        self.projects = self._get_projects_from_db(self._context,
                                                   self.flavorid)
        self.obj_reset_changes(['projects'])

    @base.lazy_load_counter
    def obj_load_attr(self, attrname):
        # NOTE(danms): Only projects could be lazy-loaded right now
        if attrname != 'projects':
            raise exception.ObjectActionError(
                action='obj_load_attr', reason='unable to load %s' % attrname)

        self._load_projects()

    def obj_reset_changes(self, fields=None, recursive=False):
        super(Flavor, self).obj_reset_changes(fields=fields,
                recursive=recursive)
        if fields is None or 'extra_specs' in fields:
            self._orig_extra_specs = (dict(self.extra_specs)
                                      if self.obj_attr_is_set('extra_specs')
                                      else {})
        if fields is None or 'projects' in fields:
            self._orig_projects = (list(self.projects)
                                   if self.obj_attr_is_set('projects')
                                   else [])

    def obj_what_changed(self):
        changes = super(Flavor, self).obj_what_changed()
        if ('extra_specs' in self and
            self.extra_specs != self._orig_extra_specs):
            changes.add('extra_specs')
        if 'projects' in self and self.projects != self._orig_projects:
            changes.add('projects')
        return changes

    @classmethod
    def _obj_from_primitive(cls, context, objver, primitive):
        self = super(Flavor, cls)._obj_from_primitive(context, objver,
                                                      primitive)
        changes = self.obj_what_changed()
        if 'extra_specs' not in changes:
            # This call left extra_specs "clean" so update our tracker
            self._orig_extra_specs = (dict(self.extra_specs)
                                      if self.obj_attr_is_set('extra_specs')
                                      else {})
        if 'projects' not in changes:
            # This call left projects "clean" so update our tracker
            self._orig_projects = (list(self.projects)
                                   if self.obj_attr_is_set('projects')
                                   else [])
        return self

    @base.remotable_classmethod
    def get_by_id(cls, context, id):
        db_flavor = cls._flavor_get_from_db(context, id)
        return cls._from_db_object(context, cls(context), db_flavor,
                                   expected_attrs=['extra_specs'])

    @base.remotable_classmethod
    def get_by_name(cls, context, name):
        db_flavor = cls._flavor_get_by_name_from_db(context, name)
        return cls._from_db_object(context, cls(context), db_flavor,
                                   expected_attrs=['extra_specs'])

    @base.remotable_classmethod
    def get_by_flavor_id(cls, context, flavor_id, read_deleted=None):
        db_flavor = cls._flavor_get_by_flavor_id_from_db(context,
                                                         flavor_id)
        return cls._from_db_object(context, cls(context), db_flavor,
                                   expected_attrs=['extra_specs'])

    @staticmethod
    def _flavor_add_project(context, flavor_id, project_id):
        return _flavor_add_project(context, flavor_id, project_id)

    @staticmethod
    def _flavor_del_project(context, flavor_id, project_id):
        return _flavor_del_project(context, flavor_id, project_id)

    def _add_access(self, project_id):
        self._flavor_add_project(self._context, self.id, project_id)

    @base.remotable
    def add_access(self, project_id):
        if 'projects' in self.obj_what_changed():
            raise exception.ObjectActionError(action='add_access',
                                              reason='projects modified')
        self._add_access(project_id)
        self._load_projects()
        self._send_notification(fields.NotificationAction.UPDATE)

    def _remove_access(self, project_id):
        self._flavor_del_project(self._context, self.id, project_id)

    @base.remotable
    def remove_access(self, project_id):
        if 'projects' in self.obj_what_changed():
            raise exception.ObjectActionError(action='remove_access',
                                              reason='projects modified')
        self._remove_access(project_id)
        self._load_projects()
        self._send_notification(fields.NotificationAction.UPDATE)

    @staticmethod
    def _flavor_create(context, updates):
        return _flavor_create(context, updates)

    @base.remotable
    def create(self):
        if self.obj_attr_is_set('id'):
            raise exception.ObjectActionError(action='create',
                                              reason='already created')

        updates = self.obj_get_changes()
        expected_attrs = []
        for attr in OPTIONAL_FIELDS:
            if attr in updates:
                expected_attrs.append(attr)
        db_flavor = self._flavor_create(self._context, updates)
        self._from_db_object(self._context, self, db_flavor,
                             expected_attrs=expected_attrs)
        self._send_notification(fields.NotificationAction.CREATE)

    @base.remotable
    def save_projects(self, to_add=None, to_delete=None):
        """Add or delete projects.

        :param:to_add: A list of projects to add
        :param:to_delete: A list of projects to remove
        """

        to_add = to_add if to_add is not None else []
        to_delete = to_delete if to_delete is not None else []

        for project_id in to_add:
            self._add_access(project_id)
        for project_id in to_delete:
            self._remove_access(project_id)
        self.obj_reset_changes(['projects'])

    @staticmethod
    def _flavor_extra_specs_add(context, flavor_id, specs, max_retries=10):
        return _flavor_extra_specs_add(context, flavor_id, specs, max_retries)

    @staticmethod
    def _flavor_extra_specs_del(context, flavor_id, key):
        return _flavor_extra_specs_del(context, flavor_id, key)

    @base.remotable
    def save_extra_specs(self, to_add=None, to_delete=None):
        """Add or delete extra_specs.

        :param:to_add: A dict of new keys to add/update
        :param:to_delete: A list of keys to remove
        """
        to_add = to_add if to_add is not None else {}
        to_delete = to_delete if to_delete is not None else []

        if to_add:
            self._flavor_extra_specs_add(self._context, self.id, to_add)

        for key in to_delete:
            self._flavor_extra_specs_del(self._context, self.id, key)
        self.obj_reset_changes(['extra_specs'])

    # NOTE(mriedem): This method is not remotable since we only expect the API
    # to be able to make updates to a flavor.
    @api_db_api.context_manager.writer
    def _save(self, context, values):
        db_flavor = context.session.query(api_models.Flavors).\
            filter_by(id=self.id).first()
        if not db_flavor:
            raise exception.FlavorNotFound(flavor_id=self.id)
        db_flavor.update(values)
        db_flavor.save(context.session)
        # Refresh ourselves from the DB object so we get the new updated_at.
        self._from_db_object(context, self, db_flavor)
        self.obj_reset_changes()

    def save(self):
        updates = self.obj_get_changes()
        projects = updates.pop('projects', None)
        extra_specs = updates.pop('extra_specs', None)
        if updates:
            # Only allowed to update from the whitelist of mutable fields.
            if set(updates.keys()) - MUTABLE_FIELDS:
                raise exception.ObjectActionError(
                    action='save', reason='read-only fields were changed')
            self._save(self._context, updates)

        if extra_specs is not None:
            deleted_keys = (set(self._orig_extra_specs.keys()) -
                            set(extra_specs.keys()))
            added_keys = self.extra_specs
        else:
            added_keys = deleted_keys = None

        if projects is not None:
            deleted_projects = set(self._orig_projects) - set(projects)
            added_projects = set(projects) - set(self._orig_projects)
        else:
            added_projects = deleted_projects = None

        # NOTE(danms): The first remotable method we call will reset
        # our of the original values for projects and extra_specs. Thus,
        # we collect the added/deleted lists for both above and /then/
        # call these methods to update them.

        if added_keys or deleted_keys:
            self.save_extra_specs(self.extra_specs, deleted_keys)

        if added_projects or deleted_projects:
            self.save_projects(added_projects, deleted_projects)

        if (added_keys or deleted_keys or added_projects or deleted_projects or
                updates):
            self._send_notification(fields.NotificationAction.UPDATE)

    @staticmethod
    def _flavor_destroy(context, flavor_id=None, flavorid=None):
        return _flavor_destroy(context, flavor_id=flavor_id, flavorid=flavorid)

    @base.remotable
    def destroy(self):
        # NOTE(danms): Historically the only way to delete a flavor
        # is via name, which is not very precise. We need to be able to
        # support the light construction of a flavor object and subsequent
        # delete request with only our name filled out. However, if we have
        # our id property, we should instead delete with that since it's
        # far more specific.
        if 'id' in self:
            db_flavor = self._flavor_destroy(self._context,
                                             flavor_id=self.id)
        else:
            db_flavor = self._flavor_destroy(self._context,
                                             flavorid=self.flavorid)
        self._from_db_object(self._context, self, db_flavor)
        self._send_notification(fields.NotificationAction.DELETE)

    def _send_notification(self, action):
        # NOTE(danms): Instead of making the below notification
        # lazy-load projects (which is a problem for instance-bound
        # flavors and compute-cell operations), just load them here.
        if 'projects' not in self:
            # If the flavor is deleted we can't lazy-load projects.
            # FlavorPayload will orphan the flavor which will make the
            # NotificationPayloadBase set projects=None in the notification
            # payload.
            if action != fields.NotificationAction.DELETE:
                self._load_projects()
        notification_type = flavor_notification.FlavorNotification
        payload_type = flavor_notification.FlavorPayload

        payload = payload_type(self)
        notification_type(
            publisher=notification.NotificationPublisher(
                host=CONF.host, source=fields.NotificationSource.API),
            event_type=notification.EventType(object="flavor",
                                              action=action),
            priority=fields.NotificationPriority.INFO,
            payload=payload).emit(self._context)


@api_db_api.context_manager.reader
def _flavor_get_all_from_db(context, inactive, filters, sort_key, sort_dir,
                            limit, marker):
    """Returns all flavors.
    """
    filters = filters or {}

    query = Flavor._flavor_get_query_from_db(context)

    if 'min_memory_mb' in filters:
        query = query.filter(
                api_models.Flavors.memory_mb >= filters['min_memory_mb'])

    if 'min_root_gb' in filters:
        query = query.filter(
                api_models.Flavors.root_gb >= filters['min_root_gb'])

    if 'disabled' in filters:
        query = query.filter(
               api_models.Flavors.disabled == filters['disabled'])

    if 'is_public' in filters and filters['is_public'] is not None:
        the_filter = [api_models.Flavors.is_public == filters['is_public']]
        if filters['is_public'] and context.project_id is not None:
            the_filter.extend([api_models.Flavors.projects.any(
                project_id=context.project_id)])
        if len(the_filter) > 1:
            query = query.filter(sa.or_(*the_filter))
        else:
            query = query.filter(the_filter[0])
    marker_row = None
    if marker is not None:
        marker_row = Flavor._flavor_get_query_from_db(context).\
                    filter_by(flavorid=marker).\
                    first()
        if not marker_row:
            raise exception.MarkerNotFound(marker=marker)

    query = sqlalchemyutils.paginate_query(query, api_models.Flavors,
                                           limit,
                                           [sort_key, 'id'],
                                           marker=marker_row,
                                           sort_dir=sort_dir)
    return [_dict_with_extra_specs(i) for i in query.all()]


@base.NovaObjectRegistry.register
class FlavorList(base.ObjectListBase, base.NovaObject):
    VERSION = '1.1'

    fields = {
        'objects': fields.ListOfObjectsField('Flavor'),
        }

    @base.remotable_classmethod
    def get_all(cls, context, inactive=False, filters=None,
                sort_key='flavorid', sort_dir='asc', limit=None, marker=None):
        api_db_flavors = _flavor_get_all_from_db(context,
                                                 inactive=inactive,
                                                 filters=filters,
                                                 sort_key=sort_key,
                                                 sort_dir=sort_dir,
                                                 limit=limit,
                                                 marker=marker)
        return base.obj_make_list(context, cls(context), objects.Flavor,
                                  api_db_flavors,
                                  expected_attrs=['extra_specs'])
