// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you 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.
package org.apache.cloudstack.acl;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.inject.Inject;

import org.apache.cloudstack.acl.dao.RoleDao;
import org.apache.cloudstack.acl.dao.RolePermissionsDao;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ApiErrorCode;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.command.admin.acl.CreateRoleCmd;
import org.apache.cloudstack.api.command.admin.acl.CreateRolePermissionCmd;
import org.apache.cloudstack.api.command.admin.acl.DeleteRoleCmd;
import org.apache.cloudstack.api.command.admin.acl.DeleteRolePermissionCmd;
import org.apache.cloudstack.api.command.admin.acl.DisableRoleCmd;
import org.apache.cloudstack.api.command.admin.acl.EnableRoleCmd;
import org.apache.cloudstack.api.command.admin.acl.ImportRoleCmd;
import org.apache.cloudstack.api.command.admin.acl.ListRolePermissionsCmd;
import org.apache.cloudstack.api.command.admin.acl.ListRolesCmd;
import org.apache.cloudstack.api.command.admin.acl.UpdateRoleCmd;
import org.apache.cloudstack.api.command.admin.acl.UpdateRolePermissionCmd;
import org.apache.cloudstack.acl.RolePermissionEntity.Permission;
import org.apache.cloudstack.context.CallContext;
import org.apache.cloudstack.framework.config.ConfigKey;
import org.apache.cloudstack.framework.config.Configurable;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;

import com.cloud.event.ActionEvent;
import com.cloud.event.EventTypes;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.user.dao.AccountDao;
import com.cloud.utils.ListUtils;
import com.cloud.utils.Pair;
import com.cloud.utils.component.ManagerBase;
import com.cloud.utils.component.PluggableService;
import com.cloud.utils.db.Transaction;
import com.cloud.utils.db.TransactionCallback;
import com.cloud.utils.db.TransactionStatus;
import com.cloud.utils.exception.CloudRuntimeException;

public class RoleManagerImpl extends ManagerBase implements RoleService, Configurable, PluggableService {

    @Inject
    private AccountDao accountDao;
    @Inject
    private RoleDao roleDao;
    @Inject
    private RolePermissionsDao rolePermissionsDao;
    @Inject
    private AccountManager accountManager;

    public void checkCallerAccess() {
        if (!isEnabled()) {
            throw new PermissionDeniedException("Dynamic api checker is not enabled, aborting role operation");
        }
        Account caller = getCurrentAccount();
        if (caller == null || caller.getRoleId() == null) {
            throw new PermissionDeniedException("Restricted API called by an invalid user account");
        }
        Role callerRole = findRole(caller.getRoleId());
        if (callerRole == null || callerRole.getRoleType() != RoleType.Admin) {
            throw new PermissionDeniedException("Restricted API called by a user account of non-Admin role type");
        }
    }

    @Override
    public boolean isEnabled() {
        return RoleService.EnableDynamicApiChecker.value();
    }

    @Override
    public Role findRole(Long id, boolean ignorePrivateRoles) {
        if (id == null || id < 1L) {
            logger.trace(String.format("Role ID is invalid [%s]", id));
            return null;
        }
        RoleVO role = roleDao.findById(id);
        if (role == null) {
            logger.trace(String.format("Role not found [id=%s]", id));
            return null;
        }
        if (!isCallerRootAdmin() && (RoleType.Admin == role.getRoleType() || (!role.isPublicRole() && ignorePrivateRoles))) {
            logger.debug("Role [{}] is either of 'Admin' type or is private and is only visible to 'Root admins'.", role);
            return null;
        }
        return role;
    }

    @Override
    public List<Role> findRoles(List<Long> ids, boolean ignorePrivateRoles) {
        List<Role> result = new ArrayList<>();
        if (CollectionUtils.isEmpty(ids)) {
            logger.trace(String.format("Role IDs are invalid [%s]", ids));
            return result;
        }

        List<RoleVO> roles = roleDao.searchByIds(ids.toArray(new Long[0]));
        if (CollectionUtils.isEmpty(roles)) {
            logger.trace(String.format("Roles not found [ids=%s]", ids));
            return result;
        }
        for (Role role : roles) {
            if (!isCallerRootAdmin() && (RoleType.Admin == role.getRoleType() || (!role.isPublicRole() && ignorePrivateRoles))) {
                logger.debug("Role [{}] is either of 'Admin' type or is private and is only visible to 'Root admins'.", role);
                continue;
            }
            result.add(role);
        }
        return result;
    }

    @Override
    public Role findRole(Long id) {
        return findRole(id, false);
    }

    /**
     * Simple call to {@link CallContext#current()} to retrieve the current calling account.
     * This method facilitates unit testing, it avoids mocking static methods.
     */
    protected Account getCurrentAccount() {
        return CallContext.current().getCallingAccount();
    }

    @Override
    public RolePermission findRolePermission(final Long id) {
        if (id == null) {
            return null;
        }
        return rolePermissionsDao.findById(id);
    }

    @Override
    public RolePermission findRolePermissionByRoleIdAndRule(final Long roleId, final String rule) {
        if (roleId == null || StringUtils.isEmpty(rule)) {
            return null;
        }

        return rolePermissionsDao.findByRoleIdAndRule(roleId, rule);
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_CREATE, eventDescription = "creating Role")
    public Role createRole(final String name, final RoleType roleType, final String description, boolean publicRole) {
        checkCallerAccess();
        if (roleType == null || roleType == RoleType.Unknown) {
            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Invalid role type provided");
        }
        return Transaction.execute(new TransactionCallback<RoleVO>() {
            @Override
            public RoleVO doInTransaction(TransactionStatus status) {
                RoleVO role = new RoleVO(name, roleType, description);
                role.setPublicRole(publicRole);
                role = roleDao.persist(role);
                CallContext.current().putContextParameter(Role.class, role.getUuid());
                return role;
            }
        });
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_CREATE, eventDescription = "creating role by cloning another role")
    public Role createRole(String name, Role role, String description, boolean publicRole) {
        checkCallerAccess();
        return Transaction.execute(new TransactionCallback<RoleVO>() {
            @Override
            public RoleVO doInTransaction(TransactionStatus status) {
                RoleVO newRole = new RoleVO(name, role.getRoleType(), description);
                newRole.setPublicRole(publicRole);
                RoleVO newRoleVO = roleDao.persist(newRole);
                if (newRoleVO == null) {
                    throw new CloudRuntimeException("Unable to create the role: " + name + ", failed to persist in DB");
                }

                List<RolePermissionVO> rolePermissions = rolePermissionsDao.findAllByRoleIdSorted(role.getId());
                if (rolePermissions != null && !rolePermissions.isEmpty()) {
                    for (RolePermissionVO permission : rolePermissions) {
                        rolePermissionsDao.persist(new RolePermissionVO(newRoleVO.getId(), permission.getRule().toString(), permission.getPermission(), permission.getDescription()));
                    }
                }

                return newRoleVO;
            }
        });
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_IMPORT, eventDescription = "importing Role")
    public Role importRole(String name, RoleType type, String description, List<Map<String, Object>> rules, boolean forced, boolean isPublicRole) {
        checkCallerAccess();
        if (StringUtils.isEmpty(name)) {
            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Invalid role name provided");
        }
        if (type == null || type == RoleType.Unknown) {
            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Invalid role type provided");
        }

        List<RoleVO> existingRoles = roleDao.findByName(name, isCallerRootAdmin());
        if (CollectionUtils.isNotEmpty(existingRoles) && !forced) {
            throw new CloudRuntimeException("Role already exists");
        }

        return Transaction.execute(new TransactionCallback<RoleVO>() {
            @Override
            public RoleVO doInTransaction(TransactionStatus status) {
                RoleVO newRole = null;
                RoleVO existingRole = roleDao.findByNameAndType(name, type, isCallerRootAdmin());
                if (existingRole != null) {
                    if (existingRole.isDefault()) {
                        throw new CloudRuntimeException("Failed to import the role: " + name + ", default role cannot be overriden");
                    }

                    //Cleanup old role permissions
                    List<? extends RolePermission> rolePermissions = rolePermissionsDao.findAllByRoleIdSorted(existingRole.getId());
                    if (rolePermissions != null && !rolePermissions.isEmpty()) {
                        for (RolePermission rolePermission : rolePermissions) {
                            rolePermissionsDao.remove(rolePermission.getId());
                        }
                    }

                    existingRole.setName(name);
                    existingRole.setRoleType(type);
                    existingRole.setDescription(description);
                    existingRole.setPublicRole(isPublicRole);
                    roleDao.update(existingRole.getId(), existingRole);

                    newRole = existingRole;
                } else {
                    RoleVO role = new RoleVO(name, type, description);
                    role.setPublicRole(isPublicRole);
                    newRole = roleDao.persist(role);
                }

                if (newRole == null) {
                    throw new CloudRuntimeException("Unable to import the role: " + name + ", failed to persist in DB");
                }

                if (rules != null && !rules.isEmpty()) {
                    for (Map<String, Object> ruleDetail : rules) {
                        Rule rule = (Rule)ruleDetail.get(ApiConstants.RULE);
                        RolePermission.Permission rulePermission = (RolePermission.Permission) ruleDetail.get(ApiConstants.PERMISSION);
                        String ruleDescription = (String) ruleDetail.get(ApiConstants.DESCRIPTION);

                        rolePermissionsDao.persist(new RolePermissionVO(newRole.getId(), rule.toString(), rulePermission, ruleDescription));
                    }
                }
                return newRole;
            }
        });
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_UPDATE, eventDescription = "updating Role")
    public Role updateRole(final Role role, final String name, final RoleType roleType, final String description, Boolean publicRole) {
        checkCallerAccess();

        if (roleType != null && roleType == RoleType.Unknown) {
            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Unknown is not a valid role type");
        }
        RoleVO roleVO = (RoleVO)role;

        if (role.isDefault()) {
            if (publicRole == null || roleType != null || !StringUtils.isAllEmpty(name, description)) {
                throw new PermissionDeniedException("Default roles cannot be updated (with the exception of making it private/public).");
            }
            roleVO.setPublicRole(publicRole);
            roleDao.update(role.getId(), roleVO);
            return role;
        }

        if (StringUtils.isNotEmpty(name)) {
            roleVO.setName(name);
        }
        if (roleType != null) {
            List<? extends Account> accounts = accountDao.findAccountsByRole(role.getId());
            if (accounts == null || accounts.isEmpty()) {
                roleVO.setRoleType(roleType);
            } else {
                throw new PermissionDeniedException("Found accounts that have role in use, won't allow to change role type");
            }
        }
        if (StringUtils.isNotEmpty(description)) {
            roleVO.setDescription(description);
        }

        if (publicRole == null) {
            publicRole = role.isPublicRole();
        }
        roleVO.setPublicRole(publicRole);
        roleDao.update(role.getId(), roleVO);
        return role;
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_DELETE, eventDescription = "deleting Role")
    public boolean deleteRole(final Role role) {
        checkCallerAccess();
        if (role == null) {
            return false;
        }
        if (role.isDefault()) {
            throw new PermissionDeniedException("Default roles cannot be deleted");
        }
        List<? extends Account> accounts = accountDao.findAccountsByRole(role.getId());
        if (accounts == null || accounts.size() == 0) {
            return Transaction.execute(new TransactionCallback<Boolean>() {
                @Override
                public Boolean doInTransaction(TransactionStatus status) {
                    List<? extends RolePermission> rolePermissions = rolePermissionsDao.findAllByRoleIdSorted(role.getId());
                    if (rolePermissions != null && !rolePermissions.isEmpty()) {
                        for (RolePermission rolePermission : rolePermissions) {
                            rolePermissionsDao.remove(rolePermission.getId());
                        }
                    }
                    if (roleDao.remove(role.getId())) {
                        RoleVO roleVO = roleDao.findByIdIncludingRemoved(role.getId());
                        roleVO.setName(null);
                        return roleDao.update(role.getId(), roleVO);
                    }
                    return false;
                }
            });
        }
        throw new PermissionDeniedException("Found accounts that have role in use, won't allow to delete role");
    }

    protected boolean updateRoleState(Role role, Role.State state) {
        checkCallerAccess();
        if (role == null) {
            return false;
        }
        if (role.getState().equals(state)) {
            throw new PermissionDeniedException(String.format("Role is already %s", state));
        }
        return Transaction.execute(new TransactionCallback<Boolean>() {
            @Override
            public Boolean doInTransaction(TransactionStatus status) {
                RoleVO roleVO = roleDao.findById(role.getId());
                roleVO.setState(state);
                return roleDao.update(role.getId(), roleVO);
            }
        });
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_ENABLE, eventDescription = "enabling Role")
    public boolean enableRole(Role role) {
        return updateRoleState(role, Role.State.ENABLED);
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_DISABLE, eventDescription = "disabling Role")
    public boolean disableRole(Role role) {
        return updateRoleState(role, Role.State.DISABLED);
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_PERMISSION_CREATE, eventDescription = "creating Role Permission")
    public RolePermission createRolePermission(final Role role, final Rule rule, final Permission permission, final String description) {
        checkCallerAccess();
        if (role.isDefault()) {
            throw new PermissionDeniedException("Role permission cannot be added for Default roles");
        }

        if (findRolePermissionByRoleIdAndRule(role.getId(), rule.toString()) != null) {
            throw new PermissionDeniedException("Rule already exists for the role: " + role.getName());
        }

        return Transaction.execute(new TransactionCallback<RolePermissionVO>() {
            @Override
            public RolePermissionVO doInTransaction(TransactionStatus status) {
                return rolePermissionsDao.persist(new RolePermissionVO(role.getId(), rule.toString(), permission, description));
            }
        });
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_PERMISSION_UPDATE, eventDescription = "updating Role Permission order")
    public boolean updateRolePermission(final Role role, final List<RolePermission> newOrder) {
        checkCallerAccess();
        if (role.isDefault()) {
            throw new PermissionDeniedException("Role permission cannot be updated for Default roles");
        }
        return role != null && newOrder != null && rolePermissionsDao.update(role, newOrder);
    }

    @Override
    public boolean updateRolePermission(Role role, RolePermission rolePermission, Permission permission) {
        checkCallerAccess();
        if (role.isDefault()) {
            throw new PermissionDeniedException("Role permission cannot be updated for Default roles");
        }
        return role != null && rolePermissionsDao.update(role, rolePermission, permission);
    }

    @Override
    @ActionEvent(eventType = EventTypes.EVENT_ROLE_PERMISSION_DELETE, eventDescription = "deleting Role Permission")
    public boolean deleteRolePermission(final RolePermission rolePermission) {
        checkCallerAccess();
        Role role = findRole(rolePermission.getRoleId());
        if (role.isDefault()) {
            throw new PermissionDeniedException("Role permission cannot be deleted for Default roles");
        }
        return rolePermission != null && rolePermissionsDao.remove(rolePermission.getId());
    }

    @Override
    public List<Role> findRolesByName(String name) {
        return findRolesByName(name, null, null, null, null).first();
    }

    @Override
    public Pair<List<Role>, Integer> findRolesByName(String name, String keyword, String state, Long startIndex, Long limit) {
        if (StringUtils.isNotBlank(name) || StringUtils.isNotBlank(keyword)) {
            Pair<List<RoleVO>, Integer> data = roleDao.findAllByName(name, keyword, state, startIndex, limit, isCallerRootAdmin());
            int removed = removeRolesIfNeeded(data.first());
            return new Pair<List<Role>,Integer>(ListUtils.toListOfInterface(data.first()), Integer.valueOf(data.second() - removed));
        }
        return new Pair<List<Role>, Integer>(new ArrayList<Role>(), 0);
    }

    /**
     *  Removes roles from the given list if the role has different or more permissions than the user's calling the method role
     */
    protected int removeRolesIfNeeded(List<? extends Role> roles) {
        if (roles.isEmpty()) {
            return 0;
        }

        Long callerRoleId = getCurrentAccount().getRoleId();
        Map<String, Permission> callerRolePermissions = getRoleRulesAndPermissions(callerRoleId);

        int count = 0;
        Iterator<? extends Role> rolesIterator = roles.iterator();
        while (rolesIterator.hasNext()) {
            Role role = rolesIterator.next();

            if (role.getId() == callerRoleId || roleHasPermission(callerRolePermissions, role)) {
                continue;
            }

            count++;
            rolesIterator.remove();
        }

        return count;
    }

    /**
     * Checks if the role of the caller account has compatible permissions of the specified role.
     * For each permission of the role of the caller, the target role needs to contain the same permission.
     *
     * @param sourceRolePermissions the permissions of the caller role.
     * @param targetRole the role that the caller role wants to access.
     * @return True if the role can be accessed with the given permissions; false otherwise.
     */
    protected boolean roleHasPermission(Map<String, Permission> sourceRolePermissions, Role targetRole) {
        Set<String> rulesAlreadyCompared = new HashSet<>();
        for (RolePermission rolePermission : findAllPermissionsBy(targetRole.getId())) {
            boolean permissionIsRegex = rolePermission.getRule().getRuleString().contains("*");

            for (String apiName : accountManager.getApiNameList()) {
                if (!rolePermission.getRule().matches(apiName) || rulesAlreadyCompared.contains(apiName)) {
                    continue;
                }

                if (rolePermission.getPermission() == Permission.ALLOW && (!sourceRolePermissions.containsKey(apiName) || sourceRolePermissions.get(apiName) == Permission.DENY)) {
                    return false;
                }

                rulesAlreadyCompared.add(apiName);

                if (!permissionIsRegex) {
                    break;
                }
            }
        }

        return true;
    }

    /**
     * Given a role ID, returns a {@link Map} containing the API name as the key and the {@link Permission} for the API as the value.
     *
     * @param roleId ID from role.
     */
    public Map<String, Permission> getRoleRulesAndPermissions(Long roleId) {
        Map<String, Permission> roleRulesAndPermissions = new HashMap<>();

        for (RolePermission rolePermission : findAllPermissionsBy(roleId)) {
            boolean permissionIsRegex = rolePermission.getRule().getRuleString().contains("*");

            for (String apiName : accountManager.getApiNameList()) {
                if (!rolePermission.getRule().matches(apiName)) {
                    continue;
                }

                if (!roleRulesAndPermissions.containsKey(apiName)) {
                    roleRulesAndPermissions.put(apiName, rolePermission.getPermission());
                }

                if (!permissionIsRegex) {
                    break;
                }
            }
        }
        return roleRulesAndPermissions;
    }

    @Override
    public List<Role> findRolesByType(RoleType roleType) {
        return findRolesByType(roleType, null, null, null).first();
    }

    @Override
    public Pair<List<Role>, Integer> findRolesByType(RoleType roleType, String state, Long startIndex, Long limit) {
        if (roleType == null || RoleType.Admin == roleType && !isCallerRootAdmin()) {
            return new Pair<List<Role>, Integer>(Collections.emptyList(), 0);
        }
        Pair<List<RoleVO>, Integer> data = roleDao.findAllByRoleType(roleType, state, startIndex, limit, isCallerRootAdmin());
        return new Pair<List<Role>,Integer>(ListUtils.toListOfInterface(data.first()), Integer.valueOf(data.second()));
    }

    @Override
    public List<Role> listRoles() {
        List<? extends Role> roles = roleDao.listAll();
        removeRolesIfNeeded(roles);
        return ListUtils.toListOfInterface(roles);
    }

    @Override
    public Pair<List<Role>, Integer> listRoles(String state, Long startIndex, Long limit) {
        Pair<List<RoleVO>, Integer> data = roleDao.listAllRoles(state, startIndex, limit, isCallerRootAdmin());
        int removed = removeRolesIfNeeded(data.first());
        return new Pair<List<Role>,Integer>(ListUtils.toListOfInterface(data.first()), Integer.valueOf(data.second() - removed));
    }

    @Override
    public List<RolePermission> findAllPermissionsBy(final Long roleId) {
        List<? extends RolePermission> permissions = rolePermissionsDao.findAllByRoleIdSorted(roleId);
        if (permissions != null) {
            return new ArrayList<>(permissions);
        }
        return Collections.emptyList();
    }

    private boolean isCallerRootAdmin() {
        return accountManager.isRootAdmin(getCurrentAccount().getId());
    }

    @Override
    public Permission getRolePermission(String permission) {
        if (StringUtils.isEmpty(permission)) {
            return null;
        }
        if (!permission.equalsIgnoreCase(RolePermission.Permission.ALLOW.toString()) &&
                !permission.equalsIgnoreCase(RolePermission.Permission.DENY.toString())) {
            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Values for permission parameter should be: allow or deny");
        }
        return permission.equalsIgnoreCase(RolePermission.Permission.ALLOW.toString()) ? RolePermission.Permission.ALLOW : RolePermission.Permission.DENY;
    }

    @Override
    public String getConfigComponentName() {
        return RoleService.class.getSimpleName();
    }

    @Override
    public ConfigKey<?>[] getConfigKeys() {
        return new ConfigKey<?>[] {EnableDynamicApiChecker, DynamicApiCheckerCachePeriod};
    }

    @Override
    public List<Class<?>> getCommands() {
        final List<Class<?>> cmdList = new ArrayList<>();
        cmdList.add(CreateRoleCmd.class);
        cmdList.add(ImportRoleCmd.class);
        cmdList.add(ListRolesCmd.class);
        cmdList.add(UpdateRoleCmd.class);
        cmdList.add(DeleteRoleCmd.class);
        cmdList.add(CreateRolePermissionCmd.class);
        cmdList.add(ListRolePermissionsCmd.class);
        cmdList.add(UpdateRolePermissionCmd.class);
        cmdList.add(DeleteRolePermissionCmd.class);
        cmdList.add(EnableRoleCmd.class);
        cmdList.add(DisableRoleCmd.class);
        return cmdList;
    }
}
