#   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 API Library"""

from typing import Any

from keystoneauth1 import exceptions as ks_exceptions
from keystoneauth1 import session as ks_session
from osc_lib import exceptions
import requests

from openstackclient.i18n import _


class KeystoneSession:
    """Wrapper for the Keystone Session

    Restore some requests.session.Session compatibility;
    keystoneauth1.session.Session.request() has the method and url
    arguments swapped from the rest of the requests-using world.

    """

    def __init__(
        self, session: Any = None, endpoint: str | None = None, **kwargs: Any
    ) -> None:
        """Base object that contains some common API objects and methods

        :param Session session:
            The default session to be used for making the HTTP API calls.
        :param string endpoint:
            The URL from the Service Catalog to be used as the base for API
            requests on this API.
        """

        super().__init__()

        # a requests.Session-style interface
        self.session = session
        self.endpoint = endpoint

    def _request(
        self, method: str, url: str, session: Any = None, **kwargs: Any
    ) -> Any:
        """Perform call into session

        All API calls are funneled through this method to provide a common
        place to finalize the passed URL and other things.

        :param string method:
            The HTTP method name, i.e. ``GET``, ``PUT``, etc
        :param string url:
            The API-specific portion of the URL path
        :param Session session:
            HTTP client session
        :param kwargs:
            keyword arguments passed to requests.request().
        :return: the requests.Response object
        """

        if not session:
            session = self.session
        if not session:
            session = ks_session.Session()

        if self.endpoint:
            if url:
                url = '/'.join([self.endpoint.rstrip('/'), url.lstrip('/')])
            else:
                url = self.endpoint.rstrip('/')

        # Why is ksc session backwards???
        return session.request(url, method, **kwargs)


class BaseAPI(KeystoneSession):
    """Base API"""

    def __init__(
        self,
        session: Any = None,
        service_type: str | None = None,
        endpoint: str | None = None,
        **kwargs: Any,
    ) -> None:
        """Base object that contains some common API objects and methods

        :param Session session:
            The default session to be used for making the HTTP API calls.
        :param string service_type:
            API name, i.e. ``identity`` or ``compute``
        :param string endpoint:
            The URL from the Service Catalog to be used as the base for API
            requests on this API.
        """

        super().__init__(session=session, endpoint=endpoint)

        self.service_type = service_type

    # The basic action methods all take a Session and return dict/lists

    def create(
        self,
        url: str,
        session: Any = None,
        method: str | None = None,
        **params: Any,
    ) -> Any:
        """Create a new resource

        :param string url:
            The API-specific portion of the URL path
        :param Session session:
            HTTP client session
        :param string method:
            HTTP method (default POST)
        """

        if not method:
            method = 'POST'
        ret = self._request(method, url, session=session, **params)
        # Should this move into _requests()?
        try:
            return ret.json()
        except requests.JSONDecodeError:
            return ret

    def delete(self, url: str, session: Any = None, **params: Any) -> Any:
        """Delete a resource

        :param string url:
            The API-specific portion of the URL path
        :param Session session:
            HTTP client session
        """

        return self._request('DELETE', url, **params)

    def list(
        self,
        path: str,
        session: Any = None,
        body: Any = None,
        detailed: bool = False,
        **params: Any,
    ) -> Any:
        """Return a list of resources

        GET ${ENDPOINT}/${PATH}?${PARAMS}

        path is often the object's plural resource type

        :param string path:
            The API-specific portion of the URL path
        :param Session session:
            HTTP client session
        :param body: data that will be encoded as JSON and passed in POST
            request (GET will be sent by default)
        :param bool detailed:
            Adds '/details' to path for some APIs to return extended attributes
        :returns:
            JSON-decoded response, could be a list or a dict-wrapped-list
        """

        if detailed:
            path = '/'.join([path.rstrip('/'), 'details'])

        if body:
            ret = self._request(
                'POST',
                path,
                json=body,
                params=params,
            )
        else:
            ret = self._request(
                'GET',
                path,
                params=params,
            )
        try:
            return ret.json()
        except requests.JSONDecodeError:
            return ret

    # Layered actions built on top of the basic action methods do not
    # explicitly take a Session but one may still be passed in kwargs

    def find_attr(
        self,
        path: str,
        value: str | None = None,
        attr: str | None = None,
        resource: str | None = None,
    ) -> Any:
        """Find a resource via attribute or ID

        Most APIs return a list wrapped by a dict with the resource
        name as key.  Some APIs (Identity) return a dict when a query
        string is present and there is one return value.  Take steps to
        unwrap these bodies and return a single dict without any resource
        wrappers.

        :param string path:
            The API-specific portion of the URL path
        :param string value:
            value to search for
        :param string attr:
            attribute to use for resource search
        :param string resource:
            plural of the object resource name; defaults to path

        For example:
            n = find(netclient, 'network', 'networks', 'matrix')
        """

        # Default attr is 'name'
        if attr is None:
            attr = 'name'

        # Default resource is path - in many APIs they are the same
        if resource is None:
            resource = path

        def getlist(kw: dict[str, Any]) -> Any:
            """Do list call, unwrap resource dict if present"""
            ret = self.list(path, **kw)
            if isinstance(ret, dict) and resource in ret:
                ret = ret[resource]
            return ret

        # Search by attribute
        kwargs: dict[str, Any] = {attr: value}
        data = getlist(kwargs)
        if isinstance(data, dict):
            return data
        if len(data) == 1:
            return data[0]
        if len(data) > 1:
            msg = _("Multiple %(resource)s exist with %(attr)s='%(value)s'")
            raise exceptions.CommandError(
                msg % {'resource': resource, 'attr': attr, 'value': value}
            )

        # Search by id
        kwargs = {'id': value}
        data = getlist(kwargs)
        if len(data) == 1:
            return data[0]
        msg = _("No %(resource)s with a %(attr)s or ID of '%(value)s' found")
        raise exceptions.CommandError(
            msg % {'resource': resource, 'attr': attr, 'value': value}
        )

    def find_bulk(self, path: str, **kwargs: Any) -> Any:
        """Bulk load and filter locally

        :param string path:
            The API-specific portion of the URL path
        :param kwargs:
            A dict of AVPs to match - logical AND
        :returns: list of resource dicts
        """

        items = self.list(path)
        if isinstance(items, dict):
            # strip off the enclosing dict
            key = next(iter(items.keys()))
            items = items[key]

        ret = []
        for o in items:
            try:
                if all(o[attr] == kwargs[attr] for attr in kwargs.keys()):
                    ret.append(o)
            except KeyError:
                continue

        return ret

    def find_one(self, path: str, **kwargs: Any) -> Any:
        """Find a resource by name or ID

        :param string path:
            The API-specific portion of the URL path
        :returns:
            resource dict
        """

        bulk_list = self.find_bulk(path, **kwargs)
        num_bulk = len(bulk_list)
        if num_bulk == 0:
            msg = _("none found")
            raise exceptions.NotFound(msg)
        elif num_bulk > 1:
            msg = _("many found")
            raise RuntimeError(msg)
        return bulk_list[0]

    def find(self, path: str, value: str, attr: str) -> Any:
        """Find a single resource by name or ID

        :param string path:
            The API-specific portion of the URL path
        :param string value:
            search expression
        :param string attr:
            name of attribute for secondary search
        """

        try:
            ret = self._request('GET', f"/{path}/{value}").json()
        except ks_exceptions.NotFound:
            kwargs = {attr: value}
            try:
                ret = self.find_one(f"/{path}/detail", **kwargs)
            except ks_exceptions.NotFound:
                msg = _("%s not found") % value
                raise exceptions.NotFound(msg)

        return ret
