/*
 * 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.plugins.observation;

import static org.apache.jackrabbit.oak.commons.PathUtils.concat;
import static org.apache.jackrabbit.oak.commons.PropertiesUtil.toInteger;
import static org.apache.jackrabbit.oak.commons.PropertiesUtil.toBoolean;

import java.util.Map;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.apache.felix.scr.annotations.Activate;
import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Modified;
import org.apache.felix.scr.annotations.Property;
import org.apache.felix.scr.annotations.Service;
import org.apache.jackrabbit.JcrConstants;
import org.apache.jackrabbit.oak.api.CommitFailedException;
import org.apache.jackrabbit.oak.api.PropertyState;
import org.apache.jackrabbit.oak.spi.commit.CommitContext;
import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
import org.apache.jackrabbit.oak.spi.commit.EditorProvider;
import org.apache.jackrabbit.oak.spi.commit.Validator;
import org.apache.jackrabbit.oak.spi.commit.ValidatorProvider;
import org.apache.jackrabbit.oak.spi.state.NodeState;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A ChangeCollectorProvider can be hooked into Oak thus enabling the collection
 * of ChangeSets of changed items of a commit, which downstream Observers can
 * then use at their convenience.
 * <p>
 * 
 * @see ChangeSet for details on what is tracked and how that data should be
 *      interpreted
 */
@Component(
        immediate = true,
        metatype = true,
        label = "Apache Jackrabbit Oak Change Collector Service",
        description = "It hooks into the commit and collects a ChangeSet of changed items of a commit which " +
                "is then used to speed up observation processing"
)
@Property(name = "type", value = ChangeCollectorProvider.TYPE, propertyPrivate = true)
@Service({ValidatorProvider.class, EditorProvider.class})
public class ChangeCollectorProvider extends ValidatorProvider {
    public static final String TYPE = "changeCollectorProvider";

    private static final Logger LOG = LoggerFactory.getLogger(ChangeCollectorProvider.class);

    public static final String COMMIT_CONTEXT_OBSERVATION_CHANGESET = "oak.observation.changeSet";

    private static final int DEFAULT_MAX_ITEMS = 50;
    @Property(longValue = DEFAULT_MAX_ITEMS, label = "Maximum Number of Collected Items (per type)", description = "Integer value indicating maximum number of individual items of changes - "
            + "such as property, nodeType, node name, path - to be collected. If there are "
            + "more changes, the collection is considered failed and marked as such. " + "Default is "
            + DEFAULT_MAX_ITEMS)
    private static final String PROP_MAX_ITEMS = "maxItems";

    private static final int DEFAULT_MAX_PATH_DEPTH = 9;
    @Property(longValue = DEFAULT_MAX_PATH_DEPTH, label = "Maximum depth of paths to collect", description = "Integer value indicating maximum depth of paths to collect. "
            + "Paths deeper than this will not be individually reported, and instead "
            + "a path at this max depth will be added. Note that this doesn't affect "
            + "any other collected item such as property, nodeType - ie those will "
            + "all be collected irrespective of this config param." + "Default is " + DEFAULT_MAX_PATH_DEPTH)
    private static final String PROP_MAX_PATH_DEPTH = "maxPathDepth";

    private static final boolean DEFAULT_ENABLED = true;
    @Property(boolValue = DEFAULT_ENABLED, label = "enable/disable this validator", 
            description = "Whether this validator is enabled. If disabled no ChangeSet will be generated. Default is "
            + DEFAULT_ENABLED)
    private static final String PROP_ENABLED = "enabled";

    /**
     * There is one CollectorSupport per validation process - it is shared
     * between multiple instances of ChangeCollector (Validator) - however it
     * can remain unsynchronized as validators are executed single-threaded.
     */
    private static class CollectorSupport {
        final CommitInfo info;
        final int maxPathDepth;
        final ChangeSetBuilder changeSetBuilder;

        public CollectorSupport(@Nonnull CommitInfo info, @Nonnull ChangeSetBuilder changeSetBuilder,
                int maxPathDepth) {
            this.info = info;
            this.changeSetBuilder = changeSetBuilder;
            this.maxPathDepth = maxPathDepth;
        }

        @Override
        public String toString() {
            return "CollectorSupport with " + changeSetBuilder;
        }
    }

    /**
     * ChangeCollectors are the actual working-horse Validators that are created
     * for each level thus as a whole propage through the entire change.
     * <p>
     * The actual data is collected via a per-commit CollectorSupport and its
     * underlying ChangeSet (the latter is where the actual changes end up in).
     * <p>
     * When finished - ie in the last==root leave() - the resulting ChangeSet is
     * marked immutable and set in the CommitContext.
     */
    private static class ChangeCollector implements Validator {

        private final CollectorSupport support;

        private final boolean isRoot;
        private final NodeState beforeParentNodeOrNull;
        private final NodeState afterParentNodeOrNull;
        private final String path;
        private final String childName;
        private final int level;

        private boolean changed;

        private static ChangeCollector newRootCollector(@Nonnull CommitInfo info, int maxItems, int maxPathDepth) {
            ChangeSetBuilder changeSetBuilder = new ChangeSetBuilder(maxItems, maxPathDepth);
            CollectorSupport support = new CollectorSupport(info, changeSetBuilder, maxPathDepth);
            return new ChangeCollector(support, true, null, null, "/", null, 0);
        }

        private ChangeCollector newChildCollector(@Nullable NodeState beforeParentNodeOrNull, @Nullable NodeState afterParentNodeOrNull, @Nonnull String childName) {
            return new ChangeCollector(support, false, beforeParentNodeOrNull, afterParentNodeOrNull, concat(path, childName), childName, level + 1);
        }

        private ChangeCollector(@Nonnull CollectorSupport support, boolean isRoot, @Nullable NodeState beforeParentNodeOrNull,
                @Nullable NodeState afterParentNodeOrNull, @Nonnull String path, @Nullable String childNameOrNull, int level) {
            this.support = support;
            this.isRoot = isRoot;
            this.beforeParentNodeOrNull = beforeParentNodeOrNull;
            this.afterParentNodeOrNull = afterParentNodeOrNull;
            this.path = path;
            this.childName = childNameOrNull;
            this.level = level;
        }

        @Override
        public String toString() {
            return "ChangeCollector[path=" + path + "]";
        }

        @Override
        public void enter(NodeState before, NodeState after) throws CommitFailedException {
            // nothing to be done here
        }

        @Override
        public void leave(NodeState before, NodeState after) throws CommitFailedException {
            // first check if we have to add anything to paths and/or nodeNames
            if (changed && level <= support.maxPathDepth) {
                support.changeSetBuilder.addParentPath(path);
            }
            if (changed && childName != null) {
                support.changeSetBuilder.addParentNodeName(childName);
            }

            if (changed){
                addParentNodeType(beforeParentNodeOrNull);
                addParentNodeType(afterParentNodeOrNull);
            }

            // then if we're not at the root, we're done
            if (!isRoot) {
                return;
            }

            // but if we're at the root, then we add the ChangeSet to the
            // CommitContext of the CommitInfo
            CommitContext commitContext = (CommitContext) support.info.getInfo().get(CommitContext.NAME);
            ChangeSet changeSet = support.changeSetBuilder.build();
            commitContext.set(COMMIT_CONTEXT_OBSERVATION_CHANGESET, changeSet);
            LOG.debug("Collected changeSet for commit {} is {}", support.info, changeSet);
        }

        @Override
        public void propertyAdded(PropertyState after) throws CommitFailedException {
            addPropertyName(after);
        }

        @Override
        public void propertyChanged(PropertyState before, PropertyState after) throws CommitFailedException {
            addPropertyName(before);
        }

        @Override
        public void propertyDeleted(PropertyState before) throws CommitFailedException {
            addPropertyName(before);
        }

        @Override
        public Validator childNodeAdded(String childName, NodeState after) throws CommitFailedException {
            changed = true;
            addToAllNodeType(after);
            return newChildCollector(null, after, childName);
        }

        @Override
        public Validator childNodeChanged(String childName, NodeState before, NodeState after)
                throws CommitFailedException {
            if (level == support.maxPathDepth) {
                // then we'll cut off further paths below.
                // to compensate, add the current path at this level
                support.changeSetBuilder.addParentPath(path);

                // however, continue normally to handle names/types/properties
                // below
            }
            
            // in theory the node type could be changed, so collecting both before and after
            addToAllNodeType(before);
            addToAllNodeType(after);

            return newChildCollector(before, after, childName);
        }

        @Override
        public Validator childNodeDeleted(String childName, NodeState before) throws CommitFailedException {
            changed = true;
            addToAllNodeType(before);
            return newChildCollector(before, null, childName);
        }
        
        private void addToAllNodeType(NodeState state) {
            String primaryType = state.getName(JcrConstants.JCR_PRIMARYTYPE);
            if (primaryType != null) {
                support.changeSetBuilder.addNodeType(primaryType);
            }
            support.changeSetBuilder.addNodeTypes(state.getNames(JcrConstants.JCR_MIXINTYPES));
        }

        private void addParentNodeType(@Nullable NodeState state) {
            if (state == null){
                return;
            }
            String primaryType = state.getName(JcrConstants.JCR_PRIMARYTYPE);
            if (primaryType != null) {
                support.changeSetBuilder.addParentNodeType(primaryType);
            }
            support.changeSetBuilder.addParentNodeTypes(state.getNames(JcrConstants.JCR_MIXINTYPES));
        }

        private void addPropertyName(PropertyState after) {
            changed = true;
            support.changeSetBuilder.addPropertyName(after.getName());
        }

    }

    private int maxItems = DEFAULT_MAX_ITEMS;

    private int maxPathDepth = DEFAULT_MAX_PATH_DEPTH;
    
    private boolean enabled = DEFAULT_ENABLED;

    @Activate
    protected void activate(ComponentContext context, Map<String, ?> config) {
        reconfig(config);
        LOG.info("activate: maxItems=" + maxItems + ", maxPathDepth=" + maxPathDepth + ", enabled=" + enabled);
    }

    @Modified
    protected void modified(final Map<String, Object> config) {
        reconfig(config);
        LOG.info("modified: maxItems=" + maxItems + ", maxPathDepth=" + maxPathDepth + ", enabled=" + enabled);
    }

    private void reconfig(Map<String, ?> config) {
        maxItems = toInteger(config.get(PROP_MAX_ITEMS), DEFAULT_MAX_ITEMS);
        maxPathDepth = toInteger(config.get(PROP_MAX_PATH_DEPTH), DEFAULT_MAX_PATH_DEPTH);
        enabled = toBoolean(config.get(PROP_ENABLED), DEFAULT_ENABLED);
    }

    /** FOR TESTING-ONLY **/
    protected void setMaxPathDepth(int maxPathDepth) {
        this.maxPathDepth = maxPathDepth;
    }

    /** FOR TESTING-ONLY **/
    protected int getMaxPathDepth() {
        return this.maxPathDepth;
    }

    /** FOR TESTING-ONLY **/
    protected void setMaxItems(int maxItems) {
        this.maxItems = maxItems;
    }

    /** FOR TESTING-ONLY **/
    protected int getMaxItems() {
        return this.maxItems;
    }

    @Override
    protected Validator getRootValidator(NodeState before, NodeState after, CommitInfo info) {
        if (!enabled) {
            return null;
        }
        if (info == null || !info.getInfo().containsKey(CommitContext.NAME)) {
            // then we cannot do change-collecting, as we can't store
            // it in the info
            return null;
        }

        return ChangeCollector.newRootCollector(info, maxItems, maxPathDepth);
    }

}
