/*
 * 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 java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

import javax.jcr.RepositoryException;
import javax.jcr.Value;
import javax.jcr.nodetype.ConstraintViolationException;
import javax.jcr.nodetype.PropertyDefinition;

import org.apache.jackrabbit.JcrConstants;
import org.apache.jackrabbit.oak.api.PropertyState;
import org.apache.jackrabbit.oak.api.Tree;
import org.apache.jackrabbit.oak.commons.PathUtils;
import org.apache.jackrabbit.oak.plugins.memory.PropertyStates;
import org.apache.jackrabbit.oak.plugins.nodetype.ReadOnlyNodeTypeManager;
import org.apache.jackrabbit.oak.plugins.tree.TreeLocation;
import org.apache.jackrabbit.oak.plugins.value.jcr.PartialValueFactory;
import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
import org.apache.jackrabbit.util.Text;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Oak level implementation of the internal {@code AuthorizableProperties} that
 * is used in those cases where no {@code Session} is associated with the
 * {@code UserManager} and only OAK API methods can be used to read and
 * modify authorizable properties.
 */
class AuthorizablePropertiesImpl implements AuthorizableProperties {

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

    private final AuthorizableImpl authorizable;
    private final PartialValueFactory valueFactory;

    AuthorizablePropertiesImpl(@NotNull AuthorizableImpl authorizable,
                               @NotNull PartialValueFactory valueFactory) {
        this.authorizable = authorizable;
        this.valueFactory = valueFactory;
    }

    //---------------------------------------------< AuthorizableProperties >---
    @NotNull
    @Override
    public Iterator<String> getNames(@NotNull String relPath) throws RepositoryException {
        String oakPath = getOakPath(relPath);
        Tree tree = getTree();
        TreeLocation location = getLocation(tree, oakPath);
        Tree parent = location.getTree();
        if (parent != null && Text.isDescendantOrEqual(tree.getPath(), parent.getPath())) {
            List<String> l = new ArrayList<>();
            for (PropertyState property : parent.getProperties()) {
                String propName = property.getName();
                if (isAuthorizableProperty(tree, location.getChild(propName), false)) {
                    l.add(valueFactory.getNamePathMapper().getJcrName(propName));
                }
            }
            return l.iterator();
        } else {
            throw new RepositoryException("Relative path " + relPath + " refers to non-existing tree or tree outside of scope of authorizable.");
        }
    }

    /**
     * @see org.apache.jackrabbit.api.security.user.Authorizable#hasProperty(String)
     */
    @Override
    public boolean hasProperty(@NotNull String relPath) throws RepositoryException {
        String oakPath = getOakPath(relPath);
        return isAuthorizableProperty(getTree(), getLocation(getTree(), oakPath), true);
    }

    /**
     * @see org.apache.jackrabbit.api.security.user.Authorizable#getProperty(String)
     */
    @Override
    public Value[] getProperty(@NotNull String relPath) throws RepositoryException {
        String oakPath = getOakPath(relPath);
        Tree tree = getTree();
        Value[] values = null;
        PropertyState property = getAuthorizableProperty(tree, getLocation(tree, oakPath), true);
        if (property != null) {
            if (property.isArray()) {
                List<Value> vs = valueFactory.createValues(property);
                values = vs.toArray(new Value[0]);
            } else {
                values = new Value[]{valueFactory.createValue(property)};
            }
        }
        return values;
    }

    /**
     * @see org.apache.jackrabbit.api.security.user.Authorizable#setProperty(String, javax.jcr.Value)
     */
    @Override
    public void setProperty(@NotNull String relPath, @Nullable Value value) throws RepositoryException {
        if (value == null) {
            removeProperty(relPath);
        } else {
            String oakPath = getOakPath(relPath);
            String name = Text.getName(oakPath);
            PropertyState propertyState = PropertyStates.createProperty(name, value);

            String intermediate = (oakPath.equals(name)) ? null : Text.getRelativeParent(oakPath, 1);
            Tree parent = getOrCreateTargetTree(intermediate);
            checkProtectedProperty(parent, propertyState);

            parent.setProperty(propertyState);
        }
    }

    /**
     * @see org.apache.jackrabbit.api.security.user.Authorizable#setProperty(String, javax.jcr.Value[])
     */
    @Override
    public void setProperty(@NotNull String relPath, @Nullable Value[] values) throws RepositoryException {
        if (values == null) {
            removeProperty(relPath);
        } else {
            String oakPath = getOakPath(relPath);
            String name = Text.getName(oakPath);
            PropertyState propertyState =
                    PropertyStates.createProperty(name, Arrays.asList(values));

            String intermediate = (oakPath.equals(name)) ? null : Text.getRelativeParent(oakPath, 1);
            Tree parent = getOrCreateTargetTree(intermediate);
            checkProtectedProperty(parent, propertyState);

            parent.setProperty(propertyState);
        }
    }

    /**
     * @see org.apache.jackrabbit.api.security.user.Authorizable#removeProperty(String)
     */
    @Override
    public boolean removeProperty(@NotNull String relPath) throws RepositoryException {
        String oakPath = getOakPath(relPath);
        Tree node = getTree();
        TreeLocation propertyLocation = getLocation(node, oakPath);
        if (propertyLocation.getProperty() != null) {
            if (isAuthorizableProperty(node, propertyLocation, true)) {
                return propertyLocation.remove();
            } else {
                throw new ConstraintViolationException("Property " + relPath + " isn't a modifiable authorizable property");
            }
        } else {
            checkScope(node.getPath(), propertyLocation.getPath(), relPath);
        }
        // no such property or wasn't a property of this authorizable.
        return false;
    }

    //------------------------------------------------------------< private >---
    @NotNull
    private Tree getTree() {
        return authorizable.getTree();
    }

    /**
     * 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 authorizableTree The tree of the target authorizable.
     * @param propertyLocation Location 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} if the given property is not protected and is defined
     *         by the rep:authorizable node type or one of it's sub-node types;
     *         {@code false} otherwise.
     * @throws RepositoryException If an error occurs.
     */
    private boolean isAuthorizableProperty(@NotNull Tree authorizableTree, @NotNull TreeLocation propertyLocation, boolean verifyAncestor) throws RepositoryException {
        return getAuthorizableProperty(authorizableTree, propertyLocation, verifyAncestor) != null;
    }

    /**
     * Returns the valid authorizable property identified by the specified
     * property location or {@code null} if that property does not exist or
     * isn't a authorizable property because it is protected or outside of the
     * scope of the {@code authorizableTree}.
     *
     * @param authorizableTree The tree of the target authorizable.
     * @param propertyLocation Location 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 a valid authorizable property or {@code null} if no such property
     *         exists or fi the property is protected or not defined by the rep:authorizable
     *         node type or one of it's sub-node types.
     * @throws RepositoryException If an error occurs.
     */
    @Nullable
    private PropertyState getAuthorizableProperty(@NotNull Tree authorizableTree, @NotNull TreeLocation propertyLocation, boolean verifyAncestor) throws RepositoryException {
        PropertyState property = propertyLocation.getProperty();
        if (property == null) {
            return null;
        }

        String authorizablePath = authorizableTree.getPath();
        if (verifyAncestor && !Text.isDescendant(authorizablePath, propertyLocation.getPath())) {
            log.debug("Attempt to access property outside of authorizable scope.");
            return null;
        }

        Tree parent = propertyLocation.getParent().getTree();
        if (parent == null) {
            log.debug("Unable to determine definition of authorizable property at {}", propertyLocation.getPath());
            return null;
        }
        ReadOnlyNodeTypeManager nodeTypeManager = authorizable.getUserManager().getNodeTypeManager();
        PropertyDefinition def = nodeTypeManager.getDefinition(parent, property, true);
        if (def.isProtected() || (authorizablePath.equals(parent.getPath())
                && !def.getDeclaringNodeType().isNodeType(UserConstants.NT_REP_AUTHORIZABLE))) {
            return null;
        } // else: non-protected property somewhere in the subtree of the user tree.

        return property;
    }

    private void checkProtectedProperty(@NotNull Tree parent, @NotNull PropertyState property) throws RepositoryException {
        ReadOnlyNodeTypeManager nodeTypeManager = authorizable.getUserManager().getNodeTypeManager();
        PropertyDefinition def = nodeTypeManager.getDefinition(parent, property, false);
        if (def.isProtected()) {
            throw new ConstraintViolationException("Attempt to set an protected property " + property.getName());
        }
    }

    /**
     * Retrieves the node at {@code relPath} relative to node associated with
     * this authorizable. If no such node exist it and any missing intermediate
     * nodes are created.
     *
     * @param relPath A relative path.
     * @return The corresponding node.
     * @throws RepositoryException If an error occurs or if {@code relPath} refers
     *                             to a node that is outside of the scope of this authorizable.
     */
    @NotNull
    private Tree getOrCreateTargetTree(@Nullable String relPath) throws RepositoryException {
        Tree targetTree;
        Tree userTree = getTree();
        if (relPath != null) {
            String userPath = userTree.getPath();
            targetTree = getLocation(userTree, relPath).getTree();
            if (targetTree != null) {
                checkScope(userPath, targetTree.getPath(), relPath);
            } else {
                targetTree = Utils.getOrAddTree(userTree, relPath, JcrConstants.NT_UNSTRUCTURED);
                checkScope(userPath, targetTree.getPath(), relPath);
            }
        } else {
            targetTree = userTree;
        }
        return targetTree;
    }

    @NotNull
    private static TreeLocation getLocation(@NotNull Tree tree, @NotNull String relativePath) {
        TreeLocation loc = TreeLocation.create(tree);
        for (String element : Text.explode(relativePath, '/', false)) {
            if (PathUtils.denotesParent(element)) {
                loc = loc.getParent();
            } else if (!PathUtils.denotesCurrent(element)) {
                loc = loc.getChild(element);
            }  // else . -> skip to next element
        }
        return loc;
    }

    @NotNull
    private String getOakPath(@NotNull String relPath) throws RepositoryException {
        if (relPath.isEmpty() || relPath.charAt(0) == '/') {
            throw new RepositoryException("Relative path expected. Found " + relPath);
        }
        String oakPath = valueFactory.getNamePathMapper().getOakPath(relPath);
        if (oakPath == null) {
            throw new RepositoryException("Failed to resolve relative path: " + relPath);
        }
        return oakPath;
    }

    private static void checkScope(@NotNull String userPath, @NotNull String targetPath, @NotNull String relPath) throws RepositoryException {
        if (!Text.isDescendantOrEqual(userPath, targetPath)) {
            throw new RepositoryException("Relative path " + relPath + " outside of scope of " + userPath);
        }
    }
}
