# -*- coding: utf-8 -*-

# Copyright 2018 Whitestack, LLC
#
# 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.
#
# For those usages not covered by the Apache License, Version 2.0 please
# contact: esousa@whitestack.com or glavado@whitestack.com
##

"""
AuthconnKeystone implements implements the connector for
Openstack Keystone and leverages the RBAC model, to bring
it for OSM.
"""


__author__ = "Eduardo Sousa <esousa@whitestack.com>"
__date__ = "$27-jul-2018 23:59:59$"

from authconn import Authconn, AuthException, AuthconnOperationException, AuthconnNotFoundException, \
    AuthconnConflictException

import logging
import requests
import time
from keystoneauth1 import session
from keystoneauth1.identity import v3
from keystoneauth1.exceptions.base import ClientException
from keystoneauth1.exceptions.http import Conflict
from keystoneclient.v3 import client
from http import HTTPStatus
from validation import is_valid_uuid


class AuthconnKeystone(Authconn):
    def __init__(self, config, db, token_cache):
        Authconn.__init__(self, config, db, token_cache)

        self.logger = logging.getLogger("nbi.authenticator.keystone")

        self.auth_url = "http://{0}:{1}/v3".format(config.get("auth_url", "keystone"), config.get("auth_port", "5000"))
        self.user_domain_name = config.get("user_domain_name", "default")
        self.admin_project = config.get("service_project", "service")
        self.admin_username = config.get("service_username", "nbi")
        self.admin_password = config.get("service_password", "nbi")
        self.project_domain_name = config.get("project_domain_name", "default")

        # Waiting for Keystone to be up
        available = None
        counter = 300
        while available is None:
            time.sleep(1)
            try:
                result = requests.get(self.auth_url)
                available = True if result.status_code == 200 else None
            except Exception:
                counter -= 1
                if counter == 0:
                    raise AuthException("Keystone not available after 300s timeout")

        self.auth = v3.Password(user_domain_name=self.user_domain_name,
                                username=self.admin_username,
                                password=self.admin_password,
                                project_domain_name=self.project_domain_name,
                                project_name=self.admin_project,
                                auth_url=self.auth_url)
        self.sess = session.Session(auth=self.auth)
        self.keystone = client.Client(session=self.sess)

    def authenticate(self, user, password, project=None, token_info=None):
        """
        Authenticate a user using username/password or token_info, plus project
        :param user: user: name, id or None
        :param password: password or None
        :param project: name, id, or None. If None first found project will be used to get an scope token
        :param token_info: previous token_info to obtain authorization
        :return: the scoped token info or raises an exception. The token is a dictionary with:
            _id:  token string id,
            username: username,
            project_id: scoped_token project_id,
            project_name: scoped_token project_name,
            expires: epoch time when it expires,

        """
        try:
            username = None
            user_id = None
            project_id = None
            project_name = None

            if user:
                if is_valid_uuid(user):
                    user_id = user
                else:
                    username = user

                # get an unscoped token firstly
                unscoped_token = self.keystone.get_raw_token_from_identity_service(
                    auth_url=self.auth_url,
                    user_id=user_id,
                    username=username,
                    password=password,
                    user_domain_name=self.user_domain_name,
                    project_domain_name=self.project_domain_name)
            elif token_info:
                unscoped_token = self.keystone.tokens.validate(token=token_info.get("_id"))
            else:
                raise AuthException("Provide credentials: username/password or Authorization Bearer token",
                                    http_code=HTTPStatus.UNAUTHORIZED)

            if not project:
                # get first project for the user
                project_list = self.keystone.projects.list(user=unscoped_token["user"]["id"])
                if not project_list:
                    raise AuthException("The user {} has not any project and cannot be used for authentication".
                                        format(user), http_code=HTTPStatus.UNAUTHORIZED)
                project_id = project_list[0].id
            else:
                if is_valid_uuid(project):
                    project_id = project
                else:
                    project_name = project

            scoped_token = self.keystone.get_raw_token_from_identity_service(
                auth_url=self.auth_url,
                project_name=project_name,
                project_id=project_id,
                user_domain_name=self.user_domain_name,
                project_domain_name=self.project_domain_name,
                token=unscoped_token["auth_token"])

            auth_token = {
                "_id": scoped_token.auth_token,
                "id": scoped_token.auth_token,
                "user_id": scoped_token.user_id,
                "username": scoped_token.username,
                "project_id": scoped_token.project_id,
                "project_name": scoped_token.project_name,
                "expires": scoped_token.expires.timestamp(),
                "issued_at": scoped_token.issued.timestamp()
            }

            return auth_token
        except ClientException as e:
            # self.logger.exception("Error during user authentication using keystone. Method: basic: {}".format(e))
            raise AuthException("Error during user authentication using Keystone: {}".format(e),
                                http_code=HTTPStatus.UNAUTHORIZED)

    def validate_token(self, token):
        """
        Check if the token is valid.

        :param token: token id to be validated
        :return: dictionary with information associated with the token:
             "expires":
             "_id": token_id,
             "project_id": project_id,
             "username": ,
             "roles": list with dict containing {name, id}
         If the token is not valid an exception is raised.
        """
        if not token:
            return

        try:
            token_info = self.keystone.tokens.validate(token=token)
            ses = {
                "_id": token_info["auth_token"],
                "id": token_info["auth_token"],
                "project_id": token_info["project"]["id"],
                "project_name": token_info["project"]["name"],
                "user_id": token_info["user"]["id"],
                "username": token_info["user"]["name"],
                "roles": token_info["roles"],
                "expires": token_info.expires.timestamp(),
                "issued_at": token_info.issued.timestamp()
            }

            return ses
        except ClientException as e:
            # self.logger.exception("Error during token validation using keystone: {}".format(e))
            raise AuthException("Error during token validation using Keystone: {}".format(e),
                                http_code=HTTPStatus.UNAUTHORIZED)

    def revoke_token(self, token):
        """
        Invalidate a token.

        :param token: token to be revoked
        """
        try:
            self.logger.info("Revoking token: " + token)
            self.keystone.tokens.revoke_token(token=token)

            return True
        except ClientException as e:
            # self.logger.exception("Error during token revocation using keystone: {}".format(e))
            raise AuthException("Error during token revocation using Keystone: {}".format(e),
                                http_code=HTTPStatus.UNAUTHORIZED)

    def create_user(self, user_info):
        """
        Create a user.

        :param user_info: full user info.
        :raises AuthconnOperationException: if user creation failed.
        :return: returns the id of the user in keystone.
        """
        try:
            new_user = self.keystone.users.create(user_info["username"], password=user_info["password"],
                                                  domain=self.user_domain_name, _admin=user_info["_admin"])
            if "project_role_mappings" in user_info.keys():
                for mapping in user_info["project_role_mappings"]:
                    self.assign_role_to_user(new_user.id, mapping["project"], mapping["role"])
            return {"username": new_user.name, "_id": new_user.id}
        except Conflict as e:
            # self.logger.exception("Error during user creation using keystone: {}".format(e))
            raise AuthconnOperationException(e, http_code=HTTPStatus.CONFLICT)
        except ClientException as e:
            # self.logger.exception("Error during user creation using keystone: {}".format(e))
            raise AuthconnOperationException("Error during user creation using Keystone: {}".format(e))

    def update_user(self, user_info):
        """
        Change the user name and/or password.

        :param user_info: user info modifications
        :raises AuthconnOperationException: if change failed.
        """
        try:
            user = user_info.get("_id") or user_info.get("username")
            if is_valid_uuid(user):
                user_obj_list = [self.keystone.users.get(user)]
            else:
                user_obj_list = self.keystone.users.list(name=user)
            if not user_obj_list:
                raise AuthconnNotFoundException("User '{}' not found".format(user))
            user_obj = user_obj_list[0]
            user_id = user_obj.id
            if user_info.get("password") or user_info.get("username") \
                    or user_info.get("add_project_role_mappings") or user_info.get("remove_project_role_mappings"):
                self.keystone.users.update(user_id, password=user_info.get("password"), name=user_info.get("username"),
                                           _admin={"created": user_obj._admin["created"], "modified": time.time()})
            for mapping in user_info.get("remove_project_role_mappings", []):
                self.remove_role_from_user(user_id, mapping["project"], mapping["role"])
            for mapping in user_info.get("add_project_role_mappings", []):
                self.assign_role_to_user(user_id, mapping["project"], mapping["role"])
        except ClientException as e:
            # self.logger.exception("Error during user password/name update using keystone: {}".format(e))
            raise AuthconnOperationException("Error during user update using Keystone: {}".format(e))

    def delete_user(self, user_id):
        """
        Delete user.

        :param user_id: user identifier.
        :raises AuthconnOperationException: if user deletion failed.
        """
        try:
            result, detail = self.keystone.users.delete(user_id)
            if result.status_code != 204:
                raise ClientException("error {} {}".format(result.status_code, detail))
            return True
        except ClientException as e:
            # self.logger.exception("Error during user deletion using keystone: {}".format(e))
            raise AuthconnOperationException("Error during user deletion using Keystone: {}".format(e))

    def get_user_list(self, filter_q=None):
        """
        Get user list.

        :param filter_q: dictionary to filter user list by name (username is also admited) and/or _id
        :return: returns a list of users.
        """
        try:
            filter_name = None
            if filter_q:
                filter_name = filter_q.get("name") or filter_q.get("username")
            users = self.keystone.users.list(name=filter_name)
            users = [{
                "username": user.name,
                "_id": user.id,
                "id": user.id,
                "_admin": user.to_dict().get("_admin", {})   # TODO: REVISE
            } for user in users if user.name != self.admin_username]

            if filter_q and filter_q.get("_id"):
                users = [user for user in users if filter_q["_id"] == user["_id"]]

            for user in users:
                user["project_role_mappings"] = []
                user["projects"] = []
                projects = self.keystone.projects.list(user=user["_id"])
                for project in projects:
                    user["projects"].append(project.name)

                    roles = self.keystone.roles.list(user=user["_id"], project=project.id)
                    for role in roles:
                        prm = {
                            "project": project.id,
                            "project_name": project.name,
                            "role_name": role.name,
                            "role": role.id,
                        }
                        user["project_role_mappings"].append(prm)

            return users
        except ClientException as e:
            # self.logger.exception("Error during user listing using keystone: {}".format(e))
            raise AuthconnOperationException("Error during user listing using Keystone: {}".format(e))

    def get_role_list(self, filter_q=None):
        """
        Get role list.

        :param filter_q: dictionary to filter role list by _id and/or name.
        :return: returns the list of roles.
        """
        try:
            filter_name = None
            if filter_q:
                filter_name = filter_q.get("name")
            roles_list = self.keystone.roles.list(name=filter_name)

            roles = [{
                "name": role.name,
                "_id": role.id,
                "_admin": role.to_dict().get("_admin", {}),
                "permissions": role.to_dict().get("permissions", {})
            } for role in roles_list if role.name != "service"]

            if filter_q and filter_q.get("_id"):
                roles = [role for role in roles if filter_q["_id"] == role["_id"]]

            return roles
        except ClientException as e:
            # self.logger.exception("Error during user role listing using keystone: {}".format(e))
            raise AuthException("Error during user role listing using Keystone: {}".format(e),
                                http_code=HTTPStatus.UNAUTHORIZED)

    def create_role(self, role_info):
        """
        Create a role.

        :param role_info: full role info.
        :raises AuthconnOperationException: if role creation failed.
        """
        try:
            result = self.keystone.roles.create(role_info["name"], permissions=role_info.get("permissions"),
                                                _admin=role_info.get("_admin"))
            return result.id
        except Conflict as ex:
            raise AuthconnConflictException(str(ex))
        except ClientException as e:
            # self.logger.exception("Error during role creation using keystone: {}".format(e))
            raise AuthconnOperationException("Error during role creation using Keystone: {}".format(e))

    def delete_role(self, role_id):
        """
        Delete a role.

        :param role_id: role identifier.
        :raises AuthconnOperationException: if role deletion failed.
        """
        try:
            result, detail = self.keystone.roles.delete(role_id)

            if result.status_code != 204:
                raise ClientException("error {} {}".format(result.status_code, detail))

            return True
        except ClientException as e:
            # self.logger.exception("Error during role deletion using keystone: {}".format(e))
            raise AuthconnOperationException("Error during role deletion using Keystone: {}".format(e))

    def update_role(self, role_info):
        """
        Change the name of a role
        :param role_info: full role info
        :return: None
        """
        try:
            rid = role_info["_id"]
            if not is_valid_uuid(rid):   # Is this required?
                role_obj_list = self.keystone.roles.list(name=rid)
                if not role_obj_list:
                    raise AuthconnNotFoundException("Role '{}' not found".format(rid))
                rid = role_obj_list[0].id
            self.keystone.roles.update(rid, name=role_info["name"], permissions=role_info.get("permissions"),
                                       _admin=role_info.get("_admin"))
        except ClientException as e:
            # self.logger.exception("Error during role update using keystone: {}".format(e))
            raise AuthconnOperationException("Error during role updating using Keystone: {}".format(e))

    def get_project_list(self, filter_q=None):
        """
        Get all the projects.

        :param filter_q: dictionary to filter project list.
        :return: list of projects
        """
        try:
            filter_name = None
            if filter_q:
                filter_name = filter_q.get("name")
            projects = self.keystone.projects.list(name=filter_name)

            projects = [{
                "name": project.name,
                "_id": project.id,
                "_admin": project.to_dict().get("_admin", {})  # TODO: REVISE
            } for project in projects]

            if filter_q and filter_q.get("_id"):
                projects = [project for project in projects
                            if filter_q["_id"] == project["_id"]]

            return projects
        except ClientException as e:
            # self.logger.exception("Error during user project listing using keystone: {}".format(e))
            raise AuthException("Error during user project listing using Keystone: {}".format(e),
                                http_code=HTTPStatus.UNAUTHORIZED)

    def create_project(self, project_info):
        """
        Create a project.

        :param project_info: full project info.
        :return: the internal id of the created project
        :raises AuthconnOperationException: if project creation failed.
        """
        try:
            result = self.keystone.projects.create(project_info["name"], self.project_domain_name,
                                                   _admin=project_info["_admin"])
            return result.id
        except ClientException as e:
            # self.logger.exception("Error during project creation using keystone: {}".format(e))
            raise AuthconnOperationException("Error during project creation using Keystone: {}".format(e))

    def delete_project(self, project_id):
        """
        Delete a project.

        :param project_id: project identifier.
        :raises AuthconnOperationException: if project deletion failed.
        """
        try:
            # projects = self.keystone.projects.list()
            # project_obj = [project for project in projects if project.id == project_id][0]
            # result, _ = self.keystone.projects.delete(project_obj)

            result, detail = self.keystone.projects.delete(project_id)
            if result.status_code != 204:
                raise ClientException("error {} {}".format(result.status_code, detail))

            return True
        except ClientException as e:
            # self.logger.exception("Error during project deletion using keystone: {}".format(e))
            raise AuthconnOperationException("Error during project deletion using Keystone: {}".format(e))

    def update_project(self, project_id, project_info):
        """
        Change the name of a project
        :param project_id: project to be changed
        :param project_info: full project info
        :return: None
        """
        try:
            self.keystone.projects.update(project_id, name=project_info["name"], _admin=project_info["_admin"])
        except ClientException as e:
            # self.logger.exception("Error during project update using keystone: {}".format(e))
            raise AuthconnOperationException("Error during project update using Keystone: {}".format(e))

    def assign_role_to_user(self, user, project, role):
        """
        Assigning a role to a user in a project.

        :param user: username.
        :param project: project name.
        :param role: role name.
        :raises AuthconnOperationException: if role assignment failed.
        """
        try:
            if is_valid_uuid(user):
                user_obj = self.keystone.users.get(user)
            else:
                user_obj_list = self.keystone.users.list(name=user)
                if not user_obj_list:
                    raise AuthconnNotFoundException("User '{}' not found".format(user))
                user_obj = user_obj_list[0]

            if is_valid_uuid(project):
                project_obj = self.keystone.projects.get(project)
            else:
                project_obj_list = self.keystone.projects.list(name=project)
                if not project_obj_list:
                    raise AuthconnNotFoundException("Project '{}' not found".format(project))
                project_obj = project_obj_list[0]

            if is_valid_uuid(role):
                role_obj = self.keystone.roles.get(role)
            else:
                role_obj_list = self.keystone.roles.list(name=role)
                if not role_obj_list:
                    raise AuthconnNotFoundException("Role '{}' not found".format(role))
                role_obj = role_obj_list[0]

            self.keystone.roles.grant(role_obj, user=user_obj, project=project_obj)
        except ClientException as e:
            # self.logger.exception("Error during user role assignment using keystone: {}".format(e))
            raise AuthconnOperationException("Error during role '{}' assignment to user '{}' and project '{}' using "
                                             "Keystone: {}".format(role, user, project, e))

    def remove_role_from_user(self, user, project, role):
        """
        Remove a role from a user in a project.

        :param user: username.
        :param project: project name or id.
        :param role: role name or id.

        :raises AuthconnOperationException: if role assignment revocation failed.
        """
        try:
            if is_valid_uuid(user):
                user_obj = self.keystone.users.get(user)
            else:
                user_obj_list = self.keystone.users.list(name=user)
                if not user_obj_list:
                    raise AuthconnNotFoundException("User '{}' not found".format(user))
                user_obj = user_obj_list[0]

            if is_valid_uuid(project):
                project_obj = self.keystone.projects.get(project)
            else:
                project_obj_list = self.keystone.projects.list(name=project)
                if not project_obj_list:
                    raise AuthconnNotFoundException("Project '{}' not found".format(project))
                project_obj = project_obj_list[0]

            if is_valid_uuid(role):
                role_obj = self.keystone.roles.get(role)
            else:
                role_obj_list = self.keystone.roles.list(name=role)
                if not role_obj_list:
                    raise AuthconnNotFoundException("Role '{}' not found".format(role))
                role_obj = role_obj_list[0]

            self.keystone.roles.revoke(role_obj, user=user_obj, project=project_obj)
        except ClientException as e:
            # self.logger.exception("Error during user role revocation using keystone: {}".format(e))
            raise AuthconnOperationException("Error during role '{}' revocation to user '{}' and project '{}' using "
                                             "Keystone: {}".format(role, user, project, e))
