/*
 * 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.api.security.principal.ItemBasedPrincipal;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.Group;
import org.apache.jackrabbit.api.security.user.User;
import org.apache.jackrabbit.commons.iterator.RangeIteratorAdapter;
import org.apache.jackrabbit.core.NodeImpl;
import org.apache.jackrabbit.core.SessionImpl;
import org.apache.jackrabbit.core.id.NodeId;
import org.apache.jackrabbit.core.nodetype.NodeTypeImpl;
import org.apache.jackrabbit.core.security.principal.EveryonePrincipal;
import org.apache.jackrabbit.core.security.principal.PrincipalImpl;
import org.apache.jackrabbit.spi.Name;
import org.apache.jackrabbit.util.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.Node;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.UnsupportedRepositoryOperationException;
import javax.jcr.Value;
import javax.jcr.nodetype.ConstraintViolationException;
import javax.jcr.nodetype.PropertyDefinition;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

/**
 * AuthorizableImpl
 */
abstract class AuthorizableImpl implements Authorizable, UserConstants {

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

    final UserManagerImpl userManager;
    private final NodeImpl node;
    private int hashCode;

    /**
     * @param node The node this Authorizable is persisted to.
     * @param userManager UserManager that created this Authorizable.
     * @throws IllegalArgumentException if the given node isn't of node type
     * {@link #NT_REP_AUTHORIZABLE}.
     */
    protected AuthorizableImpl(NodeImpl node, UserManagerImpl userManager) {
        this.node = node;
        this.userManager = userManager;
    }

    //-------------------------------------------------------< Authorizable >---
    /**
     * Returns the unescaped name of the node that defines this <code>Authorizable</code>.
     *
     * @return the unescaped name of the node that defines this <code>Authorizable</code>.
     * @see Authorizable#getID()
     */
    public String getID() throws RepositoryException {
        return Text.unescapeIllegalJcrChars(getNode().getName());
    }

    /**
     * @see Authorizable#declaredMemberOf()
     */
    public Iterator<Group> declaredMemberOf() throws RepositoryException {
        return collectMembership(false);
    }

    /**
     * @see Authorizable#memberOf()
     */
    public Iterator<Group> memberOf() throws RepositoryException {
        return collectMembership(true);
    }

    /**
     * @see Authorizable#getPropertyNames()
     */
    public Iterator<String> getPropertyNames() throws RepositoryException {
        List<String> l = new ArrayList<String>();
        for (PropertyIterator it = node.getProperties(); it.hasNext();) {
            Property prop = it.nextProperty();
            if (isAuthorizableProperty(prop, false)) {
                l.add(prop.getName());
            }
        }
        return l.iterator();
    }

    /**
     * @see Authorizable#getPropertyNames(String)
     */
    public Iterator<String> getPropertyNames(String relPath) throws RepositoryException {
        Node n = node.getNode(relPath);
        if (n.isSame(node)) {
            // same as #getPropertyNames()
            return getPropertyNames();
        } else if (Text.isDescendant(node.getPath(), n.getPath())) {
            List<String> l = new ArrayList<String>();
            for (PropertyIterator it = n.getProperties(); it.hasNext();) {
                Property prop = it.nextProperty();
                if (isAuthorizableProperty(prop, false)) {
                    l.add(prop.getName());
                }
            }
            return l.iterator();
        } else {
            throw new IllegalArgumentException("Relative path " + relPath + " refers to items outside of scope of authorizable " + getID());
        }
    }

    /**
     * @see #getProperty(String)
     */
    public boolean hasProperty(String relPath) throws RepositoryException {
        return node.hasProperty(relPath) && isAuthorizableProperty(node.getProperty(relPath), true);
    }

    /**
     * @see #hasProperty(String)
     * @see Authorizable#getProperty(String)
     */
    public Value[] getProperty(String relPath) throws RepositoryException {
        if (node.hasProperty(relPath)) {
            Property prop = node.getProperty(relPath);
            if (isAuthorizableProperty(prop, true)) {
                if (prop.isMultiple()) {
                    return prop.getValues();
                } else {
                    return new Value[]{prop.getValue()};
                }
            }
        }
        return null;
    }

    /**
     * Sets the Value for the given name. If a value existed, it is replaced,
     * if not it is created.
     *
     * @param relPath The relative path to the property or the property name.
     * @param value The property value.
     * @throws RepositoryException If the specified name defines a property
     * that needs to be modified by this user API or setting the corresponding
     * JCR property fails.
     * @see Authorizable#setProperty(String, Value)
     */
    public synchronized void setProperty(String relPath, Value value) throws RepositoryException {
        String name = Text.getName(relPath);
        String intermediate = (relPath.equals(name)) ? null : Text.getRelativeParent(relPath, 1);
        checkProtectedProperty(name);
        try {
            Node n = getOrCreateTargetNode(intermediate);
            // check if the property has already been created as multi valued
            // property before -> in this case remove in order to avoid
            // ValueFormatException.
            if (n.hasProperty(name)) {
                Property p = n.getProperty(name);
                if (p.isMultiple()) {
                    p.remove();
                }
            }
            n.setProperty(name, value);
            if (userManager.isAutoSave()) {
                node.save();
            }
        } catch (RepositoryException e) {
            log.debug("Failed to set Property " + name + " for " + this, e);
            node.refresh(false);
            throw e;
        }
    }

    /**
     * Sets the Value[] for the given name. If a value existed, it is replaced,
     * if not it is created.
     *
     * @param relPath The relative path to the property or the property name.
     * @param values The property values.
     * @throws RepositoryException If the specified name defines a property
     * that needs to be modified by this user API or setting the corresponding
     * JCR property fails.
     * @see Authorizable#setProperty(String, Value[])
     */
    public synchronized void setProperty(String relPath, Value[] values) throws RepositoryException {
        String name = Text.getName(relPath);
        String intermediate = (relPath.equals(name)) ? null : Text.getRelativeParent(relPath, 1);
        checkProtectedProperty(name);
        try {
            Node n = getOrCreateTargetNode(intermediate);
            // check if the property has already been created as single valued
            // property before -> in this case remove in order to avoid
            // ValueFormatException.
            if (n.hasProperty(name)) {
                Property p = n.getProperty(name);
                if (!p.isMultiple()) {
                    p.remove();
                }
            }
            n.setProperty(name, values);
            if (userManager.isAutoSave()) {
                node.save();
            }
        } catch (RepositoryException e) {
            log.debug("Failed to set Property " + name + " for " + this, e);
            node.refresh(false);
            throw e;
        }
    }

    /**
     * @see Authorizable#removeProperty(String)
     */
    public synchronized boolean removeProperty(String relPath) throws RepositoryException {
        String name = Text.getName(relPath);        
        checkProtectedProperty(name);
        try {
            if (node.hasProperty(relPath)) {
                Property p = node.getProperty(relPath);
                if (isAuthorizableProperty(p, true)) {
                    p.remove();
                    if (userManager.isAutoSave()) {
                        node.save();
                    }
                    return true;
                }
            }
            // no such property or wasn't a property of this authorizable.
            return false;
        } catch (RepositoryException e) {
            log.debug("Failed to remove Property " + relPath + " from " + this, e);
            node.refresh(false);
            throw e;
        }
    }

    /**
     * @see Authorizable#remove()
     */
    public synchronized void remove() throws RepositoryException {
        // don't allow for removal of the administrator even if the executing
        // session has all permissions.
        if (!isGroup() && ((User) this).isAdmin()) {
            throw new RepositoryException("The administrator cannot be removed.");
        }
        Session s = getSession();
        userManager.onRemove(this);
        node.remove();
        if (userManager.isAutoSave()) {
            s.save();
        }
    }
       
    /**
     * @see Authorizable#getPath()
     */
    public String getPath() throws UnsupportedRepositoryOperationException, RepositoryException {
        return userManager.getPath(node);
    }

    //-------------------------------------------------------------< Object >---
    @Override
    public int hashCode() {
        if (hashCode == 0) {
            try {
                StringBuilder sb = new StringBuilder();
                sb.append(isGroup() ? "group:" : "user:");
                sb.append(getSession().getWorkspace().getName());
                sb.append(":");
                sb.append(node.getIdentifier());
                hashCode = sb.toString().hashCode();
            } catch (RepositoryException e) {
            }
        }
        return hashCode;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof AuthorizableImpl) {
            AuthorizableImpl otherAuth = (AuthorizableImpl) obj;
            try {
                return isGroup() == otherAuth.isGroup() && node.isSame(otherAuth.node);
            } catch (RepositoryException e) {
                // should not occur -> return false in this case.
            }
        }
        return false;
    }

    @Override
    public String toString() {
        try {
            String typeStr = (isGroup()) ? "Group '" : "User '";
            return new StringBuilder().append(typeStr).append(getID()).append("'").toString();
        } catch (RepositoryException e) {
            return super.toString();
        }
    }

    //--------------------------------------------------------------------------
    /**
     * @return node The underlying <code>Node</code> object.
     */
    NodeImpl getNode() {
        return node;
    }

    SessionImpl getSession() throws RepositoryException {
        return (SessionImpl) node.getSession();
    }

    String getPrincipalName() throws RepositoryException {
        // principal name is mandatory property -> no check required.
        return node.getProperty(P_PRINCIPAL_NAME).getString();
    }

    boolean isEveryone() throws RepositoryException {
        return isGroup() && EveryonePrincipal.NAME.equals(getPrincipalName());
    }

    private Iterator<Group> collectMembership(boolean includeIndirect) throws RepositoryException {
        Collection<String> groupNodeIds;
        MembershipCache cache = userManager.getMembershipCache();
        String nid = node.getIdentifier();

        final long t0 = System.nanoTime();
        boolean collect = false;
        if (node.getSession().hasPendingChanges()) {
            collect = true;
            // avoid retrieving outdated cache entries or filling the cache with
            // invalid data due to group-membership changes pending on the
            // current session.
            // this is mainly for backwards compatibility reasons (no cache present)
            // where transient changes (in non-autosave mode) were reflected to the
            // editing session (see JCR-2713)
            Session session = node.getSession();
            groupNodeIds = (includeIndirect) ? cache.collectMembership(nid, session) : cache.collectDeclaredMembership(nid, session);
        } else {
            //  retrieve cached membership. there are no pending changes.
            groupNodeIds = (includeIndirect) ? cache.getMemberOf(nid) : cache.getDeclaredMemberOf(nid);
        }
        final long t1 = System.nanoTime();
        Set<Group> groups = new HashSet<Group>(groupNodeIds.size());
        for (String identifier : groupNodeIds) {
            try {
                NodeImpl n = (NodeImpl) getSession().getNodeByIdentifier(identifier);
                Group group = userManager.createGroup(n);
                groups.add(group);
            } catch (RepositoryException e) {
                // group node doesn't exist or cannot be read -> ignore.
            }
        }
        final long t2 = System.nanoTime();
        if (log.isDebugEnabled()) {
            log.debug("Collected {} {} group ids for [{}] in {}us, loaded {} groups in {}us (collect={}, cachesize={})", new Object[]{
                    groupNodeIds.size(),
                    includeIndirect ? "all" : "declared",
                    getID(),
                    (t1-t0) / 1000,
                    groups.size(),
                    (t2-t1) / 1000,
                    collect,
                    cache.getSize()
            });
        }
        return new RangeIteratorAdapter(groups.iterator(), groups.size());
    }

    /**
     * Returns true if the given property of the authorizable node is one of the
     * non-protected properties defined by the rep:Authorizable node type or a
     * some other descendant of the authorizable node.
     *
     * @param prop Property to be tested.
     * @param verifyAncestor If true the property is tested to be a descendant
     * of the node of this authorizable; otherwise it is expected that this
     * test has been executed by the caller.
     * @return <code>true</code> if the given property is defined
     * by the rep:authorizable node type or one of it's sub-node types;
     * <code>false</code> otherwise.
     * @throws RepositoryException If the property definition cannot be retrieved.
     */
    private boolean isAuthorizableProperty(Property prop, boolean verifyAncestor) throws RepositoryException {
        if (verifyAncestor && !Text.isDescendant(node.getPath(), prop.getPath())) {
            log.debug("Attempt to access property outside of authorizable scope.");
            return false;
        }

        PropertyDefinition def = prop.getDefinition();
        if (def.isProtected()) {
            return false;
        } else if (node.isSame(prop.getParent())) {
            NodeTypeImpl declaringNt = (NodeTypeImpl) prop.getDefinition().getDeclaringNodeType();
            return declaringNt.isNodeType(UserConstants.NT_REP_AUTHORIZABLE);
        } else {
            // another non-protected property somewhere in the subtree of this
            // authorizable node -> is a property that can be set using #setProperty.
            return true;
        }
    }

    /**
     * Test if the JCR property to be modified/removed is one of the
     * following that has a special meaning and must be altered using this
     * user API:
     * <ul>
     * <li>rep:principalName</li>
     * <li>rep:members</li>
     * <li>rep:impersonators</li>
     * <li>rep:password</li>
     * </ul>
     * Those properties are 'protected' in their property definition. This
     * method is a simple utility in order to save the extra effort to modify
     * the props just to find out later that they are in fact protected.
     *
     * @param propertyName Name of the property.
     * @return true if the property with the given name represents a protected
     * user/group property that needs to be changed through the API.
     * @throws RepositoryException If the specified name is not valid.
     */
    private boolean isProtectedProperty(String propertyName) throws RepositoryException {
        Name pName = getSession().getQName(propertyName);
        return P_PRINCIPAL_NAME.equals(pName)
                || P_MEMBERS.equals(pName)
                || P_IMPERSONATORS.equals(pName) || P_PASSWORD.equals(pName);
    }

    /**
     * Throws ConstraintViolationException if {@link #isProtectedProperty(String)}
     * returns <code>true</code>.
     *
     * @param propertyName Name of the property.
     * @throws ConstraintViolationException If the property is protected according
     * to {@link #isProtectedProperty(String)}.
     * @throws RepositoryException If another error occurs.
     */
    private void checkProtectedProperty(String propertyName) throws ConstraintViolationException, RepositoryException {
        if (isProtectedProperty(propertyName)) {
            throw new ConstraintViolationException("Attempt to modify protected property " + propertyName + " of " + this);
        }
    }

    /**
     * 
     * @param relPath A relative path.
     * @return The corresponding node.
     * @throws RepositoryException If an error occurs.
     */
    private Node getOrCreateTargetNode(String relPath) throws RepositoryException {
        Node n;
        if (relPath != null) {
            if (node.hasNode(relPath)) {
                n = node.getNode(relPath);
            } else {
                n = node;
                for (String segment : Text.explode(relPath, '/')) {
                    if (n.hasNode(segment)) {
                        n = n.getNode(segment);
                    } else {
                        n = n.addNode(segment);
                    }
                }
            }
            if (!Text.isDescendantOrEqual(node.getPath(), n.getPath())) {
                node.refresh(false);
                throw new RepositoryException("Relative path " + relPath + " outside of scope of " + this);
            }
        } else {
            n = node;
        }
        return n;
    }

    //--------------------------------------------------------------------------
    /**
     *
     */
    class NodeBasedPrincipal extends PrincipalImpl implements ItemBasedPrincipal {

        /**
         * @param name for the principal
         */
        NodeBasedPrincipal(String name) {
            super(name);
        }

        NodeId getNodeId() {
            return node.getNodeId();
        }

        //---------------------------------------------< ItemBasedPrincipal >---
        /**
         * Method revealing the path to the Node that represents the
         * Authorizable this principal is created for.
         *
         * @return The path of the underlying node.
         * @see ItemBasedPrincipal#getPath()
         */
        public String getPath() throws RepositoryException {
            return node.getPath();
        }
    }
}
