package com.thinkbiganalytics.metadata.modeshape.security;

/*-
 * #%L
 * thinkbig-metadata-modeshape
 * %%
 * Copyright (C) 2017 ThinkBig Analytics
 * %%
 * 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.
 * #L%
 */

import com.thinkbiganalytics.metadata.modeshape.MetadataRepositoryException;
import com.thinkbiganalytics.metadata.modeshape.support.JcrUtil;
import com.thinkbiganalytics.security.UsernamePrincipal;

import org.modeshape.jcr.ModeShapeRoles;
import org.modeshape.jcr.security.SimplePrincipal;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.security.Principal;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;

import javax.jcr.AccessDeniedException;
import javax.jcr.Node;
import javax.jcr.PathNotFoundException;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.UnsupportedRepositoryOperationException;
import javax.jcr.security.AccessControlEntry;
import javax.jcr.security.AccessControlException;
import javax.jcr.security.AccessControlList;
import javax.jcr.security.AccessControlManager;
import javax.jcr.security.AccessControlPolicy;
import javax.jcr.security.AccessControlPolicyIterator;
import javax.jcr.security.Privilege;

/**
 * Utilities to apply JCR access control changes to nodes and node hierarchies.
 */
public final class JcrAccessControlUtil {

    private JcrAccessControlUtil() {
        throw new AssertionError(JcrAccessControlUtil.class + " is a static utility class");
    }

    public Optional<UsernamePrincipal> getCurrentUser() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null) {
            return Optional.of(new UsernamePrincipal(auth.getName()));
        } else {
            return Optional.empty();
        }
    }
    
    public static boolean addPermissions(Node node, Principal principal, String... privilegeNames) {
        return addPermissions(node, principal, Arrays.asList(privilegeNames));
    }
    
    public static boolean setPermissions(Node node, Principal principal, Collection<String> privilegeNames) {
        try {
            return setPermissions(node.getSession(), node.getPath(), principal, privilegeNames);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to set permission(s) on node " + node + ": " + privilegeNames, e);
        }
    }
    
    public static boolean setPermissions(Session session, String path, Principal principal, String... privilegeNames) {
        return setPermissions(session, path, principal, Arrays.asList(privilegeNames));
        
    }

    public static boolean setPermissions(Session session, String path, Principal principal, Collection<String> privilegeNames) {
        try {
            return setPermissions(session, path, principal, asPrivileges(session, privilegeNames));
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to set permission(s) on node " + path + ": " + privilegeNames, e);
        }
    }

    public static boolean setPermissions(Session session, String path, Principal principal, Privilege... privileges) {
        return updatePermissions(session, path, principal, true, privileges);
    }

    public static boolean addPermissions(Node node, Principal principal, Collection<String> privilegeNames) {
        try {
            return addPermissions(node.getSession(), node.getPath(), principal, privilegeNames);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to add permission(s) to node " + node + ": " + privilegeNames, e);
        }
    }

    public static boolean addPermissions(Node node, Principal principal, Privilege... privileges) {
        try {
            return addPermissions(node.getSession(), node.getPath(), principal, privileges);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to add permission(s) to node " + node + ": "
                                                  + Arrays.toString(privileges), e);
        }
    }
    
    public static boolean addPermissions(Session session, String path, Principal principal, String... privilegeNames) {
        return addPermissions(session, path, principal, Arrays.asList(privilegeNames));
        
    }

    public static boolean addPermissions(Session session, String path, Principal principal, Collection<String> privilegeNames) {
        try {
            // No privileges results in a no-op.
            if (privilegeNames.size() > 0) {
                return addPermissions(session, path, principal, asPrivileges(session, privilegeNames));
            } else {
                return false;
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to add permission(s) to node " + path + ": " + privilegeNames, e);
        }
    }

    public static boolean addPermissions(Session session, String path, Principal principal, Privilege... privileges) {
        // No privileges results in a no-op.
        if (privileges.length > 0) {
            return updatePermissions(session, path, principal, false, privileges);
        } else {
            return false;
        }
    }
    
    public static boolean hasAnyPermission(Node node, Principal principal, String... privileges) {
        try {
            return hasAnyPermission(node.getSession(), node.getPath(), principal, privileges);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to check permission(s) for node: " + node);
        }
    }
    
    public static boolean hasAnyPermission(Node node, Principal principal, Privilege... privileges) {
        try {
            return hasAnyPermission(node.getSession(), node.getPath(), principal, privileges);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to check permission(s) of node " + node + ": "
                                                  + Arrays.toString(privileges), e);
        }
    }
    
    public static boolean hasAnyPermission(Session session, String path, Principal principal, String... privilegeNames) {
        try {
            return hasAnyPermission(session, path, principal, asPrivileges(session, privilegeNames));
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to check permission(s) for node: " + path);
        }
    }
    
    public static boolean hasAnyPermission(Session session, String path, Principal principal, Privilege... privileges) {
        return hasAnyPermission(session, path, principal, Arrays.asList(privileges));
    }

    public static boolean hasAnyPermission(Session session, String path, Principal principal, Collection<Privilege> privileges) {
        return getPrivileges(session, principal, path).stream().anyMatch(p -> privileges.contains(p));
    }
    
    public static Set<Privilege> getPrivileges(Session session, Principal principal, String path) {
        try {
            AccessControlManager acm = session.getAccessControlManager();
            AccessControlList acl = getAccessControlList(path, acm);
            
            for (AccessControlEntry entry : acl.getAccessControlEntries()) {
                if (entry.getPrincipal().getName().equals(principal.getName())) {
                    return new HashSet<>(Arrays.asList(entry.getPrivileges()));
                }
            }
                
            return Collections.emptySet();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to get the privileges for node " + path, e);
        }
    }

    /**
     * Adds the specified privilege to the node hierarchy starting at a child node and proceeding through its parents until
     * the destination node is reached.
     *
     * @param node           the starting node on which the privilege is assigned
     * @param principal      the principal being given the privilege
     * @param toNode         the ending parent node
     * @param privilegeNames the privilege being assigned
     * @return true if any of the nodes had their privilege change for the principle (i.e. the privilege had not already existed)
     */
    public static boolean addHierarchyPermissions(Node node, Principal principal, Node toNode, String... privilegeNames) {
        return addHierarchyPermissions(node, principal, toNode, Arrays.asList(privilegeNames));
    }

    /**
     * Adds the specified privilege to the node hierarchy starting at a child node and proceeding through its parents until
     * the destination node is reached.
     *
     * @param node           the starting node on which the privilege is assigned
     * @param principal      the principal being given the privilege
     * @param toNode         the ending parent node
     * @param privilegeNames the privilege being assigned
     * @return true if any of the nodes had their privilege change for the principle (i.e. the privilege had not already existed)
     */
    public static boolean addHierarchyPermissions(Node node, Principal principal, Node toNode, Collection<String> privilegeNames) {
        try {
            Node current = node;
            Node rootNode = toNode.getSession().getRootNode();
            AtomicBoolean added = new AtomicBoolean(false);
            Deque<Node> stack = new ArrayDeque<>();

            while (!current.equals(toNode) && !current.equals(rootNode)) {
                stack.push(current);
                current = current.getParent();
            }

            if (current.equals(rootNode) && !toNode.equals(rootNode)) {
                throw new IllegalArgumentException("addHierarchyPermissions: The \"toNode\" argument is not in the \"node\" argument's hierarchy: " + toNode);
            } else {
                stack.push(current);
            }

            stack.stream().forEach((n) -> added.compareAndSet(false, addPermissions(n, principal, privilegeNames)));

            return added.get();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to add permission(s) to hierarchy from node " + node + " up to " + toNode, e);
        }
    }
    
    public static boolean removePermissions(Session session, String path, Principal principal, String... privilegeNames) {
        try {
            return removePermissions(session, path, principal, asPrivileges(session, privilegeNames));
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove permission(s) from node " + path + ": "
                                                  + Arrays.toString(privilegeNames), e);
        }
    }
    
    public static Privilege[] asPrivileges(Session session, String... privilegeNames) throws UnsupportedRepositoryOperationException, RepositoryException, AccessControlException {
        return asPrivileges(session, Arrays.asList(privilegeNames));
    }

    public static Privilege[] asPrivileges(Session session, Collection<String> privilegeNames) throws UnsupportedRepositoryOperationException, RepositoryException, AccessControlException {
        Privilege[] privs = new Privilege[privilegeNames.size()];
        AccessControlManager acm = session.getAccessControlManager();
        int i = 0;
        
        for (String name : privilegeNames) {
            privs[i++] = acm.privilegeFromName(name);
        }

        return privs;
    }

    public static boolean removePermissions(Node node, Principal principal, String... privilegeNames) {
        try {
            return removePermissions(node.getSession(), node.getPath(), principal, privilegeNames);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove permission(s) from node " + node + ": " + Arrays.toString(privilegeNames), e);
        }
    }

    public static boolean removePermissions(Node node, Principal principal, Privilege... privileges) {
        try {
            return removePermissions(node.getSession(), node.getPath(), principal, privileges);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove permission(s) from node " + node + ": " + Arrays.toString(privileges), e);
        }

    }

    public static boolean removePermissions(Session session, String path, Principal principal, Privilege... removes) {
        try {
            // if there are no permissions specified or the principal is "admin" then do nothing.
            // There should alwasy be an ACL entry for "admin".
            if (removes.length > 0 && ! principal.getName().equals(ModeShapeRoles.ADMIN)) {
                AccessControlManager acm = session.getAccessControlManager();
                AccessControlPolicy[] aclArray = acm.getPolicies(path);

                if (aclArray.length > 0) {
                    AccessControlList acl = (AccessControlList) aclArray[0];
                    boolean removed = false;

                    for (AccessControlEntry entry : acl.getAccessControlEntries()) {
                        if (entry.getPrincipal().getName().equals(principal.getName())) {
                            Privilege[] newPrivs = Arrays.stream(entry.getPrivileges())
                                .filter(p -> !Arrays.stream(removes).anyMatch(r -> r.equals(p)))
                                .toArray(Privilege[]::new);

                            if (entry.getPrivileges().length != newPrivs.length) {
                                acl.removeAccessControlEntry(entry);
                                
                                if (newPrivs.length != 0) {
                                    acl.addAccessControlEntry(entry.getPrincipal(), newPrivs);
                                }
                                removed = true;
                            }
                        }
                    }

                    acm.setPolicy(path, acl);

                    return removed;
                } else {
                    return false;
                }
            } else {
                return false;
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove permission(s) from node " + path + ": "
                                                  + Arrays.toString(removes), e);
        }

    }
    
    public static boolean removeAllPermissions(Node node, Principal principal) {
        try {
            return removeAllPermissions(node.getSession(), node.getPath(), principal);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to renove all permission(s) from node: " + node);
        }
    }
    
    public static boolean removeAllPermissions(Session session, String path, Principal principal) {
        try {
            AccessControlManager acm = session.getAccessControlManager();
            AccessControlPolicy[] aclArray = acm.getPolicies(path);
            
            // Never remove permissions for "admin".
            if (aclArray.length > 0 && ! principal.getName().equals(ModeShapeRoles.ADMIN)) {
                AccessControlList acl = (AccessControlList) aclArray[0];
                boolean removed = removeEntry(acl, principal);
                
                acm.setPolicy(path, acl);
                
                return removed;
            } else {
                return false;
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove all permission(s) from node " + path, e);
        }
        
    }

    public static boolean removeHierarchyPermissions(Node node, Principal principal, Node toNode, String... privilegeNames) {
        try {
            Node current = node;
            Node rootNode = toNode.getSession().getRootNode();
            boolean removed = false;

            while (!current.equals(toNode) && !current.equals(rootNode)) {
                removed |= removePermissions(node.getSession(), current.getPath(), principal, privilegeNames);
                current = current.getParent();
            }

            if (current.equals(rootNode) && !toNode.equals(rootNode)) {
                throw new IllegalArgumentException("removeHierarchyPermissions: The \"toNode\" argument is not in the \"node\" argument's hierarchy: " + toNode);
            } else {
                removed |= removePermissions(node.getSession(), current.getPath(), principal, privilegeNames);
            }

            return removed;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove permission(s) from hierarch from node " + node + " up to " + toNode, e);
        }
    }
    
    public static boolean removeHierarchyAllPermissions(Node node, Principal principal, Node toNode) {
        try {
            Node current = node;
            Node rootNode = toNode.getSession().getRootNode();
            boolean removed = false;
            
            while (!current.equals(toNode) && !current.equals(rootNode)) {
                removed |= removeAllPermissions(node.getSession(), current.getPath(), principal);
                current = current.getParent();
            }
            
            if (current.equals(rootNode) && !toNode.equals(rootNode)) {
                throw new IllegalArgumentException("removeHierarchyAllPermissions: The \"toNode\" argument is not in the \"node\" argument's hierarchy: " + toNode);
            } else {
                removed |= removeAllPermissions(node.getSession(), current.getPath(), principal);
            }
            
            return removed;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove all permission(s) from hierarch from node " + node + " up to " + toNode, e);
        }
    }
    
    public static boolean clearPermissions(Node node) {
        try {
            return clearPermissions(node.getSession(), node.getPath());
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove all permission(s) from node " + node, e);
        }
    }

    public static boolean clearPermissions(Session session, String path) {
        try {
            AccessControlManager acm = session.getAccessControlManager();
            AccessControlPolicy[] acls = acm.getPolicies(path);

            if (acls.length > 0) {
                for (AccessControlPolicy policy : acm.getPolicies(path)) {
                    acm.removePolicy(path, policy);
                }

                return true;
            } else {
                return false;
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove all permission(s) from node " + path, e);
        }

    }
    
    public static boolean clearRecursivePermissions(Node node, String nodeType) {
        boolean cleared = false;
        
        for (Node child : JcrUtil.getNodesOfType(node, nodeType)) {
            cleared |= clearRecursivePermissions(child, nodeType);
            cleared |= clearPermissions(child);
        }
        
        return cleared;
    }
    

    public static boolean clearHierarchyPermissions(Node node, Node toNode) {
        try {
            Node current = node;
            Node rootNode = toNode.getSession().getRootNode();
            boolean removed = false;

            while (!current.equals(toNode) && !current.equals(rootNode)) {
                removed |= clearPermissions(current);
                current = current.getParent();
            }

            if (current.equals(rootNode) && !toNode.equals(rootNode)) {
                throw new IllegalArgumentException("clearHierarchyPermissions: The \"toNode\" argument is not in the \"node\" argument's hierarchy: " + toNode);
            } else {
                removed |= clearPermissions(current);
            }

            return removed;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to add permission(s) to hierarch from node " + node + " up to " + toNode, e);
        }
    }


    public static boolean addRecursivePermissions(Node node, String nodeType, Principal principal, String... privilegeNames) {
        try {
            // No privileges results in a no-op.
            if (privilegeNames.length > 0) {
                return addRecursivePermissions(node, nodeType, principal, asPrivileges(node.getSession(), privilegeNames));
            } else {
                return false;
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to add permission(s) to node tree " + node, e);
        }
    }

    public static boolean addRecursivePermissions(Node node, String nodeType, Principal principal, Privilege... privileges) {
        // No privileges results in a no-op.
        if (privileges.length > 0) {
            boolean added = addPermissions(node, principal, privileges);

            for (Node child : JcrUtil.getNodesOfType(node, nodeType)) {
                added |= addRecursivePermissions(child, nodeType, principal, privileges);
            }

            return added;
        } else {
            return false;
        }
    }

    public static boolean removeRecursivePermissions(Node node, String nodeType, Principal principal, String... privilegeNames) {
        try {
            // No privileges results in a no-op.
            if (privilegeNames.length > 0) {
                return removeRecursivePermissions(node, nodeType, principal, asPrivileges(node.getSession(), privilegeNames));
            } else {
                return false;
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove permission(s) from node tree " + node, e);
        }
    }

    public static boolean removeRecursivePermissions(Node node, String nodeType, Principal principal, Privilege... privileges) {
        try {
            // No privileges results in a no-op.
            if (privileges.length > 0) {
                boolean removed = false;
    
                for (Node child : JcrUtil.getNodesOfType(node, nodeType)) {
                    removed |= removeRecursivePermissions(child, nodeType, principal, privileges);
                }
    
                return removePermissions(node.getSession(), node.getPath(), principal, privileges) || removed;
            } else {
                return false;
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove permission(s) from node tree " + node, e);
        }
    }
    
    public static boolean removeRecursiveAllPermissions(Node node, String nodeType, Principal principal) {
        try {
            boolean removed = false;
            
            for (Node child : JcrUtil.getNodesOfType(node, nodeType)) {
                removed |= removeRecursiveAllPermissions(child, nodeType, principal);
            }
            
            return removeAllPermissions(node.getSession(), node.getPath(), principal) || removed;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove permission(s) from node tree " + node, e);
        }
    }

    private static boolean updatePermissions(Session session, String path, Principal principal, boolean replace, Privilege... privileges) {
        try {
            AccessControlManager acm = session.getAccessControlManager();
            AccessControlList acl = getAccessControlList(path, acm);
            boolean changed = false;
            
            if (replace) {
                changed |= removeEntry(acl, principal);
            }
            
            if (privileges.length > 0) {
                changed |= addEntry(session, acl, principal, privileges);
            }
            
            acm.setPolicy(path, acl);
            return changed;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to add permission(s) to node " + path + ": "
                                                  + Arrays.toString(privileges), e);
        }
    }

    private static boolean addEntry(Session session, AccessControlList acl, Principal principal, Privilege... privileges) throws RepositoryException, AccessControlException,
                                                                                                                          UnsupportedRepositoryOperationException {
        // Ensure admin is always included in the ACL
        if (acl.getAccessControlEntries().length == 0) {
            SimplePrincipal simple = SimplePrincipal.newInstance(ModeShapeRoles.ADMIN);
            acl.addAccessControlEntry(simple, asPrivileges(session, Privilege.JCR_ALL));
        }
    
        // ModeShape reads back all principals as SimplePrincipals after they are stored, so we have to use
        // the same principal type here or the entry will treated as a new one instead of adding privileges to the 
        // to an existing principal.  This can be considered a bug in ModeShape.
        SimplePrincipal simple = SimplePrincipal.newInstance(principal.getName());
        boolean added = acl.addAccessControlEntry(simple, privileges);
        return added;
    }

    private static boolean removeEntry(AccessControlList acl, Principal principal) throws RepositoryException {
        boolean removed = false;
        
        for (AccessControlEntry entry : acl.getAccessControlEntries()) {
            if (entry.getPrincipal().getName().equals(principal.getName())) {
                acl.removeAccessControlEntry(entry);
                removed = true;
            }
        }
        
        return removed;
    }

    private static AccessControlList getAccessControlList(String path, AccessControlManager acm) throws PathNotFoundException, AccessDeniedException, RepositoryException {
        AccessControlList acl = null;
        AccessControlPolicyIterator it = acm.getApplicablePolicies(path);
    
        if (it.hasNext()) {
            acl = (AccessControlList) it.nextAccessControlPolicy();
        } else {
            acl = (AccessControlList) acm.getPolicies(path)[0];
        }
        return acl;
    }

    /**
     * Tests if the specified privilege implies, either directly or as an aggregate, the named privilege.
     * @param checked the privilege being checked
     * @param implied the name of the privilege to be implied
     * @return true if the check privilege implies the given privilege name
     */
    private static boolean implies(Privilege checked, String implied) {
        if (checked.getName().equals(implied)) {
            return true;
        } else if (checked.isAggregate()) {
            return impliesAny(checked.getAggregatePrivileges(), implied);
        } else {
            return false;
        }
    }

    /**
     * Tests if any of the specified privileges imply, either directly or as an aggregate, the named privilege.
     * @param privileges an array of privileges
     * @param implied the name of the privilege to be implied
     * @return true if the any of the privileges implies the given privilege name
     */
    private static boolean impliesAny(Privilege[] privileges, String implied) {
        for (Privilege checked : privileges) {
            if (implies(checked, implied)) {
                return true;
            }
        }
        
        return false;
    }
}
