/*
 * 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.jackrabbit.oak.security.user;

import org.apache.jackrabbit.guava.common.base.Stopwatch;
import org.apache.jackrabbit.guava.common.base.Strings;
import org.apache.jackrabbit.guava.common.collect.Maps;
import org.apache.jackrabbit.guava.common.collect.Sets;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.Group;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.jackrabbit.commons.iterator.RangeIteratorAdapter;
import org.apache.jackrabbit.oak.api.Tree;
import org.apache.jackrabbit.oak.spi.security.user.AuthorizableType;
import org.apache.jackrabbit.oak.spi.security.user.DynamicMembershipProvider;
import org.apache.jackrabbit.oak.spi.security.user.util.UserUtil;
import org.apache.jackrabbit.oak.spi.xml.ImportBehavior;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.RepositoryException;
import javax.jcr.nodetype.ConstraintViolationException;
import java.security.Principal;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;

import static java.util.concurrent.TimeUnit.NANOSECONDS;

/**
 * GroupImpl...
 */
class GroupImpl extends AuthorizableImpl implements Group {

    private static final Logger log = LoggerFactory.getLogger(GroupImpl.class);

    GroupImpl(String id, Tree tree, UserManagerImpl userManager) throws RepositoryException {
        super(id, tree, userManager);
    }

    //---------------------------------------------------< AuthorizableImpl >---
    @Override
    void checkValidTree(@NotNull Tree tree) {
        if (!UserUtil.isType(tree, AuthorizableType.GROUP)) {
            throw new IllegalArgumentException("Invalid group node: node type rep:Group expected.");
        }
    }

    //-------------------------------------------------------< Authorizable >---
    @Override
    public boolean isGroup() {
        return true;
    }

    @NotNull
    @Override
    public Principal getPrincipal() throws RepositoryException {
        return new GroupPrincipal(getPrincipalName(), getTree());
    }

    //--------------------------------------------------------------< Group >---
    @NotNull
    @Override
    public Iterator<Authorizable> getDeclaredMembers() throws RepositoryException {
        return getMembersMonitored(false);
    }

    @NotNull
    @Override
    public Iterator<Authorizable> getMembers() throws RepositoryException {
        return getMembersMonitored(true);
    }

    @Override
    public boolean isDeclaredMember(@NotNull Authorizable authorizable) throws RepositoryException {
        return isMember(authorizable, false);
    }

    @Override
    public boolean isMember(@NotNull Authorizable authorizable) throws RepositoryException {
        return isMember(authorizable, true);
    }

    @Override
    public boolean addMember(@NotNull Authorizable authorizable) throws RepositoryException {
        Stopwatch watch = Stopwatch.createStarted();
        boolean success = internalAddMember(authorizable);
        getMonitor().doneUpdateMembers(watch.elapsed(NANOSECONDS), 1, (success) ? 0 : 1, false);
        return success;
    }

    private boolean internalAddMember(@NotNull Authorizable authorizable) throws RepositoryException {
        if (!isValidAuthorizableImpl(authorizable)) {
            log.warn("Invalid Authorizable: {}", authorizable);
            return false;
        }

        DynamicMembershipProvider dmp = getUserManager().getDynamicMembershipProvider();
        if (dmp.coversAllMembers(this)) {
            log.debug("Attempt to add member to dynamic group {}", getID());
            return false;
        }
        AuthorizableImpl authorizableImpl = ((AuthorizableImpl) authorizable);
        if (authorizableImpl.isEveryone()) {
            log.debug("Attempt to create membership for everyone group.");
            return false;
        }

        String memberID = authorizable.getID();
        if (authorizableImpl.isGroup()) {
            if (getID().equals(memberID)) {
                String msg = "Attempt to add a group as member of itself (" + getID() + ").";
                log.debug(msg);
                return false;
            }
            if (isCyclicMembership((Group) authorizable)) {
                String msg = "Cyclic group membership detected for group " + getID() + " and member " + authorizable.getID();
                throw new ConstraintViolationException(msg);
            }
        }

        boolean success = getMembershipProvider().addMember(getTree(), authorizableImpl.getTree());

        if (success) {
            getUserManager().onGroupUpdate(this, false, authorizable);
        }

        return success;
    }

    @NotNull
    @Override
    public Set<String> addMembers(@NotNull String... memberIds) throws RepositoryException {
        return updateMembersMonitored(false, memberIds);
    }

    @Override
    public boolean removeMember(@NotNull Authorizable authorizable) throws RepositoryException {
        Stopwatch watch = Stopwatch.createStarted();
        boolean success = internalRemoveMember(authorizable);
        getMonitor().doneUpdateMembers(watch.elapsed(NANOSECONDS), 1, (success) ? 0 : 1, true);
        return success;
    }

    private boolean internalRemoveMember(@NotNull Authorizable authorizable) throws RepositoryException {
        if (!isValidAuthorizableImpl(authorizable)) {
            log.warn("Invalid Authorizable: {}", authorizable);
            return false;
        }

        DynamicMembershipProvider dmp = getUserManager().getDynamicMembershipProvider();
        if (dmp.coversAllMembers(this)) {
            log.debug("Attempt to remove member from dynamic group {}", getID());
            return false;
        }
        
        AuthorizableImpl authorizableImpl = ((AuthorizableImpl) authorizable);
        if (authorizableImpl.isEveryone()) {
            log.debug("Attempt to remove membership for everyone group.");
            return false;
        } else {
            Tree memberTree = authorizableImpl.getTree();
            boolean success = getMembershipProvider().removeMember(getTree(), memberTree);
            if (success) {
                getUserManager().onGroupUpdate(this, true, authorizable);
            }
            return success;
        }
    }

    @NotNull
    @Override
    public Set<String> removeMembers(@NotNull String... memberIds) throws RepositoryException {
        return updateMembersMonitored(true, memberIds);
    }

    //--------------------------------------------------------------------------
    @NotNull
    private Iterator<Authorizable> getMembersMonitored(boolean includeInherited) throws RepositoryException {
        Stopwatch watch = Stopwatch.createStarted();
        Iterator<Authorizable> members = getMembers(includeInherited);
        getMonitor().doneGetMembers(watch.elapsed(NANOSECONDS), !includeInherited);
        return members;
    }

    /**
     * Internal implementation of {@link #getDeclaredMembers()} and {@link #getMembers()}.
     *
     * @param includeInherited Flag indicating if only the declared or all members
     * should be returned.
     * @return Iterator of authorizables being member of this group.
     * @throws RepositoryException If an error occurs.
     */
    @NotNull
    private Iterator<Authorizable> getMembers(boolean includeInherited) throws RepositoryException {
        UserManagerImpl userMgr = getUserManager();

        DynamicMembershipProvider dmp = getUserManager().getDynamicMembershipProvider();
        Iterator<Authorizable> dynamicMembers = dmp.getMembers(this, includeInherited);
        if (dmp.coversAllMembers(this)) {
            return AuthorizableIterator.create(true, dynamicMembers);
        }

        // dynamic membership didn't cover all members -> extract from group-tree
        Iterator<Tree> trees = getMembershipProvider().getMembers(getTree(), includeInherited);
        if (!trees.hasNext()) {
            return AuthorizableIterator.create(true, dynamicMembers);
        }
        
        Iterator<Authorizable> members = AuthorizableIterator.create(trees, userMgr, AuthorizableType.AUTHORIZABLE);
        if (includeInherited) {
            // need to resolve dynamic members of declared and inherited group-members 
            members = new InheritedMembersIterator(members, dmp);
        }
        AuthorizableIterator allMembers = AuthorizableIterator.create(true, dynamicMembers, members);
        return new RangeIteratorAdapter(allMembers, allMembers.getSize()); 
    }

    /**
     * Internal implementation of {@link #isDeclaredMember(Authorizable)} and {@link #isMember(Authorizable)}.
     *
     * @param authorizable The authorizable to test.
     * @param includeInherited Flag indicating if only declared or all members
     * should taken into account.
     * @return {@code true} if the specified authorizable is member or declared
     * member of this group; {@code false} otherwise.
     * @throws RepositoryException If an error occurs.
     */
    private boolean isMember(@NotNull Authorizable authorizable, boolean includeInherited) throws RepositoryException {
        if (!isValidAuthorizableImpl(authorizable)) {
            return false;
        }
        if (getID().equals(authorizable.getID()) || ((AuthorizableImpl) authorizable).isEveryone()) {
            return false;
        }

        DynamicMembershipProvider dmp = getUserManager().getDynamicMembershipProvider();
        if (dmp.isMember(this, authorizable, includeInherited)) {
            return true;
        }

        // no dynamic membership -> regular membership provider needs to evaluate
        Tree authorizableTree = ((AuthorizableImpl) authorizable).getTree();
        MembershipProvider mgr = getUserManager().getMembershipProvider();
        if (includeInherited) {
            return mgr.isMember(this.getTree(), authorizableTree);
        } else {
            return mgr.isDeclaredMember(this.getTree(), authorizableTree);
        }
    }

    @NotNull
    private Set<String> updateMembersMonitored(boolean isRemove, @NotNull String... memberIds) throws RepositoryException {
        Stopwatch watch = Stopwatch.createStarted();
        Set<String> failed = updateMembers(isRemove, memberIds);
        getMonitor().doneUpdateMembers(watch.elapsed(NANOSECONDS), memberIds.length, failed.size(), isRemove);
        return failed;
    }

    /**
     * Internal method to add or remove members by ID.
     *
     * @param isRemove Boolean flag indicating if members should be added or removed.
     * @param memberIds The {@code memberIds} to be added or removed.
     * @return The sub-set of {@code memberIds} that could not be added/removed.
     * @throws javax.jcr.nodetype.ConstraintViolationException If any of the specified
     * IDs is empty string or null or if {@link org.apache.jackrabbit.oak.spi.xml.ImportBehavior#ABORT}
     * is configured and an ID cannot be resolved to an existing (or accessible)
     * authorizable.
     * @throws javax.jcr.RepositoryException If another error occurs.
     */
    @NotNull
    private Set<String> updateMembers(boolean isRemove, @NotNull String... memberIds) throws RepositoryException {
        Set<String> failedIds = Sets.newHashSet(memberIds);
        int importBehavior = UserUtil.getImportBehavior(getUserManager().getConfig());

        DynamicMembershipProvider dmp = getUserManager().getDynamicMembershipProvider();
        if (dmp.coversAllMembers(this)) {
            String msg = "Attempt to add to or remove from dynamic group {}.";
            log.debug(msg, getID());
            return failedIds;
        }

        // calculate the contentID for each memberId and remember ids that cannot be processed
        Map<String, String> updateMap = Maps.newHashMapWithExpectedSize(memberIds.length);
        MembershipProvider mp = getMembershipProvider();
        for (String memberId : memberIds) {
            if (Strings.isNullOrEmpty(memberId)) {
                throw new ConstraintViolationException("MemberId must not be null or empty.");
            }
            if (isValidMemberId(memberId, importBehavior)) {
                // memberId can be processed -> remove from failedIds and generate contentID
                failedIds.remove(memberId);
                updateMap.put(mp.getContentID(memberId), memberId);
            }
        }

        Set<String> processedIds = Sets.newHashSet(updateMap.values());
        if (!updateMap.isEmpty()) {
            Set<String> result;
            if (isRemove) {
                result = mp.removeMembers(getTree(), updateMap);
            } else {
                result = mp.addMembers(getTree(), updateMap);
            }
            failedIds.addAll(result);
            processedIds.removeAll(result);
        }

        getUserManager().onGroupUpdate(this, isRemove, false, processedIds, failedIds);
        return failedIds;
    }

    private boolean isValidMemberId(@NotNull String memberId, int importBehavior) throws RepositoryException {
        if (getID().equals(memberId)) {
            log.debug("Attempt to add or remove a group as member of itself ({}).", getID());
            return false;
        }

        if (ImportBehavior.BESTEFFORT != importBehavior) {
            Authorizable member = getUserManager().getAuthorizable(memberId);
            String msg = null;
            if (member == null) {
                msg = "Attempt to add or remove a non-existing member '" + memberId + "' with ImportBehavior = " + ImportBehavior.nameFromValue(importBehavior);
            } else if (member.isGroup()) {
                if (((AuthorizableImpl) member).isEveryone()) {
                    log.debug("Attempt to add everyone group as member.");
                    return false;
                } else if (isCyclicMembership((Group) member)) {
                    msg = "Cyclic group membership detected for group " + getID() + " and member " + member.getID();
                }
            }
            if (msg != null) {
                if (ImportBehavior.ABORT == importBehavior) {
                    throw new ConstraintViolationException(msg);
                } else {
                    // ImportBehavior.IGNORE is default in UserUtil.getImportBehavior
                    log.debug(msg);
                    return false;
                }
            }
        }
        return true;
    }

    private boolean isCyclicMembership(@NotNull Group member) throws RepositoryException {
        return member.isMember(this);
    }

    /**
     * Principal representation of this group instance.
     */
    private final class GroupPrincipal extends AbstractGroupPrincipal {

        private GroupPrincipal(String principalName, Tree groupTree) {
            super(principalName, groupTree, GroupImpl.this.getUserManager().getNamePathMapper());
        }

        @Override
        UserManager getUserManager() {
            return GroupImpl.this.getUserManager();
        }

        @Override
        boolean isEveryone() {
            return GroupImpl.this.isEveryone();
        }

        @Override
        boolean isMember(@NotNull Authorizable authorizable) throws RepositoryException {
            return GroupImpl.this.isMember(authorizable);
        }

        @NotNull
        @Override
        Iterator<Authorizable> getMembers() throws RepositoryException {
            return GroupImpl.this.getMembers();
        }
    }
}
