# Copyright 2010 Jacob Kaplan-Moss

# Copyright 2011 OpenStack Foundation
# 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.

"""
Base utilities to build API operation managers and objects on top of.
"""

import abc
import contextlib
import copy
import hashlib
import os

from oslo_utils import strutils

from manilaclient import api_versions
from manilaclient.common import cliutils
from manilaclient import exceptions
from manilaclient import utils


def getid(obj):
    """Return id if argument is a Resource.

    Abstracts the common pattern of allowing both an object or an object's ID
    (UUID) as a parameter when dealing with relationships.
    """
    try:
        if obj.uuid:
            return obj.uuid
    except AttributeError:
        pass
    try:
        return obj.id
    except AttributeError:
        return obj


class Manager(utils.HookableMixin):
    """Manager for CRUD operations.

    Managers interact with a particular type of API (shares, snapshots,
    etc.) and provide CRUD operations for them.
    """

    resource_class = None

    def __init__(self, api):
        self.api = api
        self.client = api.client

    @property
    def api_version(self):
        return self.api.api_version

    def _list(
        self, url, response_key, manager=None, body=None, return_raw=None
    ):
        """List the collection.

        :param url: a partial URL, e.g., '/shares'
        :param response_key: the key to be looked up in response dictionary,
            e.g., 'shares'. If response_key is None - all response body
            will be used.
        :param manager: manager instance for constructing the returned objects
            (self will be used by default)
        :param body: data that will be encoded as JSON and passed in POST
            request (GET will be sent by default)
        """
        resp = None
        if body:
            resp, body = self.api.client.post(url, body=body)
        else:
            resp, body = self.api.client.get(url)

        if manager is None:
            manager = self

        obj_class = manager.resource_class

        data = body[response_key]
        # NOTE(ja): keystone returns values as list as {'values': [ ... ]}
        #           unlike other services which just return the list...
        if isinstance(data, dict):
            try:
                data = data['values']
            except KeyError:
                pass
        with self.completion_cache('human_id', obj_class, mode="w"):
            with self.completion_cache('uuid', obj_class, mode="w"):
                if return_raw:
                    return data
                resource = [
                    obj_class(manager, res, loaded=True) for res in data if res
                ]
                if 'count' in body:
                    return resource, body['count']
                else:
                    return resource

    @contextlib.contextmanager
    def completion_cache(self, cache_type, obj_class, mode):
        """Bash autocompletion items storage.

        The completion cache store items that can be used for bash
        autocompletion, like UUIDs or human-friendly IDs.

        A resource listing will clear and repopulate the cache.

        A resource create will append to the cache.

        Delete is not handled because listings are assumed to be performed
        often enough to keep the cache reasonably up-to-date.
        """
        base_dir = cliutils.env(
            'manilaclient_UUID_CACHE_DIR',
            'MANILACLIENT_UUID_CACHE_DIR',
            default="~/.cache/manilaclient",
        )

        # NOTE(sirp): Keep separate UUID caches for each username + endpoint
        # pair
        username = cliutils.env('OS_USERNAME', 'MANILA_USERNAME')
        url = cliutils.env('OS_URL', 'MANILA_URL')
        uniqifier = hashlib.sha256(
            username.encode('utf-8') + url.encode('utf-8')
        ).hexdigest()

        cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier))

        try:
            os.makedirs(cache_dir, 0o755)
        except OSError:
            # NOTE(kiall): This is typically either permission denied while
            #              attempting to create the directory, or the directory
            #              already exists. Either way, don't fail.
            pass

        resource = obj_class.__name__.lower()
        filename = "{}-{}-cache".format(resource, cache_type.replace('_', '-'))
        path = os.path.join(cache_dir, filename)

        cache_attr = f"_{cache_type}_cache"

        try:
            setattr(self, cache_attr, open(path, mode))
        except OSError:
            # NOTE(kiall): This is typically a permission denied while
            #              attempting to write the cache file.
            pass

        try:
            yield
        finally:
            cache = getattr(self, cache_attr, None)
            if cache:
                cache.close()
                delattr(self, cache_attr)

    def write_to_completion_cache(self, cache_type, val):
        cache = getattr(self, f"_{cache_type}_cache", None)
        if cache:
            try:
                cache.write(f"{val}\n")
            except UnicodeEncodeError:
                pass

    def _get(self, url, response_key, return_raw=False):
        resp, body = self.api.client.get(url)
        if response_key:
            if return_raw:
                return body[response_key]
            return self.resource_class(self, body[response_key], loaded=True)
        else:
            return self.resource_class(self, body, loaded=True)

    def _get_with_base_url(self, url, response_key=None):
        resp, body = self.api.client.get_with_base_url(url)
        if response_key:
            return [
                self.resource_class(self, res, loaded=True)
                for res in body[response_key]
                if res
            ]
        else:
            return self.resource_class(self, body, loaded=True)

    def _create(self, url, body, response_key, return_raw=False, **kwargs):
        self.run_hooks('modify_body_for_create', body, **kwargs)
        resp, body = self.api.client.post(url, body=body)
        if return_raw:
            return body[response_key]

        with self.completion_cache('human_id', self.resource_class, mode="a"):
            with self.completion_cache('uuid', self.resource_class, mode="a"):
                return self.resource_class(self, body[response_key])

    def _accept(self, url, body):
        resp, body = self.api.client.post(url, body=body)

    def _delete(self, url):
        resp, body = self.api.client.delete(url)

    def _update(self, url, body, response_key=None, **kwargs):
        self.run_hooks('modify_body_for_update', body, **kwargs)
        resp, body = self.api.client.put(url, body=body)
        if body:
            if response_key:
                return self.resource_class(self, body[response_key])
            else:
                return self.resource_class(self, body)

    def _build_query_string(self, search_opts):
        search_opts = search_opts or {}
        params = sorted([(k, v) for (k, v) in search_opts.items() if v])
        query_string = f"?{utils.safe_urlencode(params)}" if params else ''
        return query_string


class ManagerWithFind(Manager):
    """Like a `Manager`, but with additional `find()`/`findall()` methods."""

    def find(self, **kwargs):
        """Find a single item with attributes matching ``**kwargs``.

        This isn't very efficient: it loads the entire list then filters on
        the Python side.
        """
        matches = self.findall(**kwargs)
        num_matches = len(matches)
        if num_matches == 0:
            msg = f"No {self.resource_class.__name__} matching {kwargs}."
            raise exceptions.NotFound(404, msg)
        elif num_matches > 1:
            raise exceptions.NoUniqueMatch
        else:
            return matches[0]

    def findall(self, **kwargs):
        """Find all items with attributes matching ``**kwargs``.

        This isn't very efficient: it loads the entire list then filters on
        the Python side.
        """
        found = []
        searches = list(kwargs.items())

        search_opts = {'all_tenants': 1}
        resources = self.list(search_opts=search_opts)
        if 'v2.shares.ShareManager' in str(
            self.__class__
        ) and self.api_version >= api_versions.APIVersion("2.69"):
            search_opts_2 = {'all_tenants': 1, 'is_soft_deleted': True}
            shares_soft_deleted = self.list(search_opts=search_opts_2)
            resources += shares_soft_deleted
        for obj in resources:
            try:
                if all(
                    getattr(obj, attr) == value for (attr, value) in searches
                ):
                    found.append(obj)
            except AttributeError:
                continue

        return found

    def list(self, search_opts=None):
        raise NotImplementedError


class Resource:
    """Base class for OpenStack resources (tenant, user, etc.).

    This is pretty much just a bag for attributes.
    """

    HUMAN_ID = False
    NAME_ATTR = 'name'

    def __init__(self, manager, info, loaded=False):
        """Populate and bind to a manager.

        :param manager: BaseManager object
        :param info: dictionary representing resource attributes
        :param loaded: prevent lazy-loading if set to True
        """
        self.manager = manager
        self._info = info
        self._add_details(info)
        self._loaded = loaded

    def __repr__(self):
        reprkeys = sorted(
            k for k in self.__dict__.keys() if k[0] != '_' and k != 'manager'
        )
        info = ", ".join(f"{k}={getattr(self, k)}" for k in reprkeys)
        return f"<{self.__class__.__name__} {info}>"

    @property
    def human_id(self):
        """Human-readable ID which can be used for bash completion."""
        if self.HUMAN_ID:
            name = getattr(self, self.NAME_ATTR, None)
            if name is not None:
                return strutils.to_slug(name)
        return None

    def _add_details(self, info):
        for k, v in info.items():
            try:
                setattr(self, k, v)
                self._info[k] = v
            except AttributeError:
                # In this case we already defined the attribute on the class
                pass

    def __getattr__(self, k):
        if k not in self.__dict__:
            # NOTE(bcwaldon): disallow lazy-loading if already loaded once
            if not self.is_loaded():
                self.get()
                return self.__getattr__(k)

            raise AttributeError(k)
        else:
            return self.__dict__[k]

    def get(self):
        """Support for lazy loading details.

        Some clients, such as novaclient have the option to lazy load the
        details, details which can be loaded with this function.
        """
        # set_loaded() first ... so if we have to bail, we know we tried.
        self.set_loaded(True)
        if not hasattr(self.manager, 'get'):
            return

        new = self.manager.get(self.id)
        if new:
            self._add_details(new._info)

    def __eq__(self, other):
        if not isinstance(other, Resource):
            return NotImplemented
        # two resources of different types are not equal
        if not isinstance(other, self.__class__):
            return False
        if hasattr(self, 'id') and hasattr(other, 'id'):
            return self.id == other.id
        return self._info == other._info

    def __ne__(self, other):
        return not self == other

    def is_loaded(self):
        return self._loaded

    def set_loaded(self, val):
        self._loaded = val

    def to_dict(self):
        return copy.deepcopy(self._info)


class MetadataCapableResource(Resource, metaclass=abc.ABCMeta):
    superresource = None

    def _get_subresource_and_resource(self, superresource):
        resource = self
        subresource = None
        superresource = superresource or self.superresource
        if superresource is not None:
            resource = superresource
            subresource = self
        return resource, subresource

    def get_metadata(self, superresource=None):
        """Get metadata of a resource

        :param superresource: either a parent resource object or text with
            its ID. Required for sub-resources such as share export
            locations which do not include a reference to the parent object
            by default
        """

        resource, subresource = self._get_subresource_and_resource(
            superresource
        )

        return self.manager.get_metadata(resource, subresource=subresource)

    def set_metadata(self, metadata, superresource=None):
        """Set or update metadata for the resource.

        :param metadata: A dictionary of key:value pairs to be set as
            resource metadata
        :param superresource: either a parent resource object or text with
            its ID. Required for sub-resources such as share share export
            locations which do not include a reference to the parent object
            by default
        """
        resource, subresource = self._get_subresource_and_resource(
            superresource
        )

        return self.manager.set_metadata(
            resource, metadata, subresource=subresource
        )

    def delete_metadata(self, keys, superresource=None):
        """Delete specified keys from the given resource.

        :param keys: An iterable with keys of metadata items to be deleted
        :param superresource: either a parent resource object or text with
            its ID. Required for sub-resources such as share share export
            locations which do not include a reference to the parent object
            by default
        """
        resource, subresource = self._get_subresource_and_resource(
            superresource
        )

        return self.manager.delete_metadata(
            resource, keys, subresource=subresource
        )

    def update_all_metadata(self, metadata, superresource=None):
        """Update all metadata for this resource.

        :param metadata: A dictionary of key:value pairs of resource metadata
            to be updated
        :param superresource: either a parent resource object or text with
            its ID. Required for sub-resources such as share share export
            locations which do not include a reference to the parent object
            by default
        """
        resource, subresource = self._get_subresource_and_resource(
            superresource
        )

        return self.manager.update_all_metadata(
            resource, metadata, subresource=subresource
        )


class MetadataCapableManager(ManagerWithFind, metaclass=abc.ABCMeta):
    """Provides extended behavior to objects to handle key=value metadata."""

    resource_path = None
    subresource_path = None

    def get_metadata(self, resource, subresource=None):
        """Get metadata of a resource.

        :param resource: either resource object or text with its ID.
        :param subresource: either a child resource object or text with its ID
        """
        resource = getid(resource)
        if subresource:
            subresource = getid(subresource)
            resource = f"{resource}{self.subresource_path}/{subresource}"

        return self._get(
            f"{self.resource_path}/{resource}/metadata", "metadata"
        )

    def set_metadata(self, resource, metadata, subresource=None):
        """Set or update metadata for resource.

        :param resource: either resource object or text with its ID.
        :param metadata: A dictionary of key:value pairs to be set as
            resource metadata
        :param subresource: either a child resource object or text with its ID
        """
        body = {'metadata': metadata}
        resource = getid(resource)
        if subresource:
            subresource = getid(subresource)
            resource = f"{resource}{self.subresource_path}/{subresource}"

        return self._create(
            f"{self.resource_path}/{resource}/metadata", body, "metadata"
        )

    def delete_metadata(self, resource, keys, subresource=None):
        """Delete specified keys from resource metadata.

        :param resource: either resource object or text with its ID.
        :param keys: An iterable with keys of metadata items to be deleted
        :param subresource: either a child resource object or text with its ID
        """
        resource = getid(resource)
        if subresource:
            subresource = getid(subresource)
            resource = f"{resource}{self.subresource_path}/{subresource}"

        for key in keys:
            self._delete(f"{self.resource_path}/{resource}/metadata/{key}")

    def update_all_metadata(self, resource, metadata, subresource=None):
        """Update all metadata of a resource.

        :param resource: either resource object or text with its ID.
        :param metadata: A dictionary of key:value pairs of resource metadata
            to be updated
        :param subresource: either a child resource object or text with its ID
        """
        body = {'metadata': metadata}
        resource = getid(resource)
        if subresource:
            subresource = getid(subresource)
            resource = f"{resource}{self.subresource_path}/{subresource}"

        return self._update(f"{self.resource_path}/{resource}/metadata", body)
