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

import org.apache.jackrabbit.core.cache.GrowingLRUMap;
import org.apache.jackrabbit.core.NodeImpl;
import org.apache.jackrabbit.core.PropertyImpl;
import org.apache.jackrabbit.core.SessionImpl;
import org.apache.jackrabbit.core.SessionListener;
import org.apache.jackrabbit.core.nodetype.NodeTypeImpl;
import org.apache.jackrabbit.core.observation.SynchronousEventListener;
import org.apache.jackrabbit.spi.Name;
import org.apache.jackrabbit.spi.commons.name.NameConstants;
import org.apache.jackrabbit.util.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.AccessDeniedException;
import javax.jcr.ItemNotFoundException;
import javax.jcr.ItemVisitor;
import javax.jcr.Node;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;
import javax.jcr.observation.Event;
import javax.jcr.observation.EventIterator;
import javax.jcr.util.TraversingItemVisitor;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * <code>MembershipCache</code>...
 */
public class MembershipCache implements UserConstants, SynchronousEventListener, SessionListener {

    /**
     * logger instance
     */
    private static final Logger log = LoggerFactory.getLogger(MembershipCache.class);

    private final SessionImpl systemSession;
    private final String groupsPath;
    private final boolean useMembersNode;
    private final String pMembers;
    private final Map<String, Collection<String>> cache;

    @SuppressWarnings("unchecked")
    MembershipCache(SessionImpl systemSession, String groupsPath, boolean useMembersNode) throws RepositoryException {
        this.systemSession = systemSession;
        this.groupsPath = (groupsPath == null) ? UserConstants.GROUPS_PATH : groupsPath;
        this.useMembersNode = useMembersNode;

        pMembers = systemSession.getJCRName(UserManagerImpl.P_MEMBERS);
        cache = new GrowingLRUMap(1024, 5000);

        String[] ntNames = new String[] {
                systemSession.getJCRName(UserConstants.NT_REP_GROUP),
                systemSession.getJCRName(UserConstants.NT_REP_MEMBERS)
        };
        // register event listener to be informed about membership changes.
        systemSession.getWorkspace().getObservationManager().addEventListener(this,
                Event.PROPERTY_ADDED | Event.PROPERTY_CHANGED | Event.PROPERTY_REMOVED,
                groupsPath,
                true,
                null,
                ntNames,
                false);
        // make sure the membership cache is informed if the system session is
        // logged out in order to stop listening to events.
        systemSession.addListener(this);
    }


    //------------------------------------------------------< EventListener >---
    /**
     * @see javax.jcr.observation.EventListener#onEvent(javax.jcr.observation.EventIterator)
     */
    public void onEvent(EventIterator eventIterator) {
        // evaluate if the membership cache needs to be cleared;
        boolean clear = false;
        while (eventIterator.hasNext() && !clear) {
            Event ev = eventIterator.nextEvent();
            try {
                if (pMembers.equals(Text.getName(ev.getPath()))) {
                    // simple case: a rep:members property that is affected
                    clear = true;
                } else if (useMembersNode) {
                    // test if it affects a property defined by rep:Members node type.
                    int type = ev.getType();
                    if (type == Event.PROPERTY_ADDED || type == Event.PROPERTY_CHANGED) {
                        Property p = systemSession.getProperty(ev.getPath());
                        Name declNtName = ((NodeTypeImpl) p.getDefinition().getDeclaringNodeType()).getQName();
                        clear = NT_REP_MEMBERS.equals(declNtName);
                    } else {
                        // PROPERTY_REMOVED
                        // test if the primary node type of the parent node is rep:Members
                        // this could potentially by some other property as well as the
                        // rep:Members node are not protected and could changed by
                        // adding a mixin type.
                        // ignoring this and simply clear the cache
                        String parentId = ev.getIdentifier();
                        Node n = systemSession.getNodeByIdentifier(parentId);
                        Name ntName = ((NodeTypeImpl) n.getPrimaryNodeType()).getQName();
                        clear = (UserConstants.NT_REP_MEMBERS.equals(ntName));
                    }
                }
            } catch (RepositoryException e) {
                log.warn(e.getMessage());
                // exception while processing the event -> clear the cache to
                // be sure it isn't outdated.
                clear = true;
            }
        }

        if (clear) {
            synchronized (cache) {
                cache.clear();
            }
        }
    }

    //----------------------------------------------------< SessionListener >---
    /**
     * @see SessionListener#loggingOut(org.apache.jackrabbit.core.SessionImpl)
     */
    public void loggingOut(SessionImpl session) {
        try {
            systemSession.getWorkspace().getObservationManager().removeEventListener(this);
        } catch (RepositoryException e) {
            log.error("Unexpected error: Failed to stop event listening of MembershipCache.", e);
        }

    }

    /**
     * @see SessionListener#loggedOut(org.apache.jackrabbit.core.SessionImpl)
     */
    public void loggedOut(SessionImpl session) {
        // nothing to do
    }

    //--------------------------------------------------------------------------
    /**
     * @param authorizableNodeIdentifier The identifier of the node representing
     * the authorizable to retrieve the declared membership for.
     * @return A collection of node identifiers of those group nodes the
     * authorizable in question is declared member of.
     * @throws RepositoryException If an error occurs.
     */
    synchronized Collection<String> getDeclaredMemberOf(String authorizableNodeIdentifier) throws RepositoryException {
        return declaredMemberOf(authorizableNodeIdentifier);
    }

    /**
     * @param authorizableNodeIdentifier The identifier of the node representing
     * the authorizable to retrieve the membership for.
     * @return A collection of node identifiers of those group nodes the
     * authorizable in question is a direct or indirect member of.
     * @throws RepositoryException If an error occurs.
     */
    synchronized Collection<String> getMemberOf(String authorizableNodeIdentifier) throws RepositoryException {
        Set<String> groupNodeIds = new HashSet<String>();
        memberOf(authorizableNodeIdentifier, groupNodeIds);
        return Collections.unmodifiableCollection(groupNodeIds);
    }

    /**
     * Collects the declared memberships for the specified identifier of an
     * authorizable using the specified session.
     * 
     * @param authorizableNodeIdentifier The identifier of the node representing
     * the authorizable to retrieve the membership for.
     * @param session The session to be used to read the membership information.
     * @return @return A collection of node identifiers of those group nodes the
     * authorizable in question is a direct member of.
     * @throws RepositoryException If an error occurs.
     */
    Collection<String> collectDeclaredMembership(String authorizableNodeIdentifier, Session session) throws RepositoryException {
        Collection<String> groupNodeIds = collectDeclaredMembershipFromReferences(authorizableNodeIdentifier, session);
        if (groupNodeIds == null) {
            groupNodeIds = collectDeclaredMembershipFromTraversal(authorizableNodeIdentifier, session);
        }
        return groupNodeIds;
    }

    /**
     * Collects the complete memberships for the specified identifier of an
     * authorizable using the specified session.
     *
     * @param authorizableNodeIdentifier The identifier of the node representing
     * the authorizable to retrieve the membership for.
     * @param session The session to be used to read the membership information.
     * @return A collection of node identifiers of those group nodes the
     * authorizable in question is a direct or indirect member of.
     * @throws RepositoryException If an error occurs.
     */
    Collection<String> collectMembership(String authorizableNodeIdentifier, Session session) throws RepositoryException {
        Set<String> groupNodeIds = new HashSet<String>();
        memberOf(authorizableNodeIdentifier, groupNodeIds, session);
        return groupNodeIds;
    }

    //------------------------------------------------------------< private >---
    /**
     * @param authorizableNodeIdentifier
     * @return
     * @throws RepositoryException
     */
    private Collection<String> declaredMemberOf(String authorizableNodeIdentifier) throws RepositoryException {
        Collection<String> groupNodeIds = cache.get(authorizableNodeIdentifier);
        if (groupNodeIds == null) {
            // retrieve a new session with system-subject in order to avoid
            // concurrent read operations using the system session of this workspace.
            Session session = getSession();
            try {
                groupNodeIds = collectDeclaredMembership(authorizableNodeIdentifier, session);
                cache.put(authorizableNodeIdentifier, Collections.unmodifiableCollection(groupNodeIds));
            }
            finally {
                // release session if it isn't the original system session
                if (session != systemSession) {
                    session.logout();
                }
            }
        }
        return groupNodeIds;
    }

    /**
     * 
     * @param authorizableNodeIdentifier
     * @param groupNodeIds
     * @throws RepositoryException
     */
    private void memberOf(String authorizableNodeIdentifier, Collection<String> groupNodeIds) throws RepositoryException {
        Collection<String> declared = declaredMemberOf(authorizableNodeIdentifier);
        for (String identifier : declared) {
            if (groupNodeIds.add(identifier)) {
                memberOf(identifier, groupNodeIds);
            }
        }
    }

    /**
     * 
     * @param authorizableNodeIdentifier
     * @param groupNodeIds
     * @param session
     * @throws RepositoryException
     */
    private void memberOf(String authorizableNodeIdentifier, Collection<String> groupNodeIds, Session session) throws RepositoryException {
        Collection<String> declared = collectDeclaredMembership(authorizableNodeIdentifier, session);
        for (String identifier : declared) {
            if (groupNodeIds.add(identifier)) {
                memberOf(identifier, groupNodeIds, session);
            }
        }
    }

    /**
     * 
     * @param authorizableNodeIdentifier
     * @param session
     * @return
     * @throws RepositoryException
     */
    private Collection<String> collectDeclaredMembershipFromReferences(String authorizableNodeIdentifier,
                                                                       Session session) throws RepositoryException {
        Set<String> pIds = new HashSet<String>();
        Set<String> nIds = new HashSet<String>();

        // Try to get membership information from references
        PropertyIterator refs = getMembershipReferences(authorizableNodeIdentifier, session);
        if (refs == null) {
            return null;
        }

        while (refs.hasNext()) {
            try {
                PropertyImpl pMember = (PropertyImpl) refs.nextProperty();
                NodeImpl nGroup = (NodeImpl) pMember.getParent();

                Set<String> groupNodeIdentifiers;
                if (P_MEMBERS.equals(pMember.getQName())) {
                    // Found membership information in members property
                    groupNodeIdentifiers = pIds;
                } else {
                    // Found membership information in members node
                    groupNodeIdentifiers = nIds;
                    while (nGroup.isNodeType(NT_REP_MEMBERS)) {
                        nGroup = (NodeImpl) nGroup.getParent();
                    }
                }

                if (nGroup.isNodeType(NT_REP_GROUP)) {
                    groupNodeIdentifiers.add(nGroup.getIdentifier());
                } else {
                    // weak-ref property 'rep:members' that doesn't reside under an
                    // group node -> doesn't represent a valid group member.
                    log.debug("Invalid member reference to '" + this + "' -> Not included in membership set.");
                }
            } catch (ItemNotFoundException e) {
                // group node doesn't exist  -> -> ignore exception
                // and skip this reference from membership list.
            } catch (AccessDeniedException e) {
                // not allowed to see the group node -> ignore exception
                // and skip this reference from membership list.
            }
        }

        // Based on the user's setting return either of the found membership information
        return select(pIds, nIds);
    }

    private Collection<String> collectDeclaredMembershipFromTraversal(
            final String authorizableNodeIdentifier, Session session) throws RepositoryException {

        final Set<String> pIds = new HashSet<String>();
        final Set<String> nIds = new HashSet<String>();

        // workaround for failure of Node#getWeakReferences
        // traverse the tree below groups-path and collect membership manually.
        log.info("Traversing groups tree to collect membership.");
        ItemVisitor visitor = new TraversingItemVisitor.Default() {
            @Override
            protected void entering(Property property, int level) throws RepositoryException {
                PropertyImpl pMember = (PropertyImpl) property;
                NodeImpl nGroup = (NodeImpl) pMember.getParent();
                if (P_MEMBERS.equals(pMember.getQName()) && nGroup.isNodeType(NT_REP_GROUP)) {
                    // Found membership information in members property
                    for (Value value : property.getValues()) {
                        String v = value.getString();
                        if (v.equals(authorizableNodeIdentifier)) {
                            pIds.add(nGroup.getIdentifier());
                        }
                    }
                } else {
                    // Found membership information in members node
                    while (nGroup.isNodeType(NT_REP_MEMBERS)) {
                        nGroup = (NodeImpl) nGroup.getParent();
                    }

                    if (nGroup.isNodeType(NT_REP_GROUP) && !NameConstants.JCR_UUID.equals(pMember.getQName())) {
                        String v = pMember.getString();
                        if (v.equals(authorizableNodeIdentifier)) {
                            nIds.add(nGroup.getIdentifier());
                        }
                    }
                }
            }
        };

        if (session.nodeExists(groupsPath)) {
            Node groupsNode = session.getNode(groupsPath);
            visitor.visit(groupsNode);
        } // else: no groups exist -> nothing to do.

        // Based on the user's setting return either of the found membership information
        return select(pIds, nIds);
    }

    /**
     * Return either of both sets depending on the users setting whether
     * to use the members property or the members node to record membership
     * information. If both sets are non empty, the one configured in the
     * settings will take precedence and an warning is logged.
     *
     * @param pIds
     * @param nIds
     * @return
     */
    private Set<String> select(Set<String> pIds, Set<String> nIds) {
        Set<String> result;
        if (useMembersNode) {
            if (!nIds.isEmpty() || pIds.isEmpty()) {
                result = nIds;
            } else {
                result = pIds;
            }
        } else {
            if (!pIds.isEmpty() || nIds.isEmpty()) {
                result = pIds;
            } else {
                result = nIds;
            }
        }

        if (!pIds.isEmpty() && !nIds.isEmpty()) {
            log.warn("Found members node and members property. Ignoring {} members",
                    useMembersNode ? "property" : "node");
        }

        return result;
    }


    /**
     * @return a new Session that needs to be properly released after usage.
     */
    private SessionImpl getSession() {
        try {
            return (SessionImpl) systemSession.createSession(systemSession.getWorkspace().getName());
        } catch (RepositoryException e) {
            // fallback
            return systemSession;
        }
    }

    private static PropertyIterator getMembershipReferences(String authorizableNodeIdentifier,
                                                            Session session) {

        PropertyIterator refs = null;
        try {
            refs = session.getNodeByIdentifier(authorizableNodeIdentifier).getWeakReferences(null);
        } catch (RepositoryException e) {
            log.error("Failed to retrieve membership references of " + authorizableNodeIdentifier + ".", e);
        }
        return refs;
    }
}