/*
 * 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 java.util.Collections.addAll;
import static org.apache.jackrabbit.oak.plugins.observation.filter.VisibleFilter.VISIBLE_FILTER;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.apache.jackrabbit.oak.api.PropertyState;
import org.apache.jackrabbit.oak.api.Type;
import org.apache.jackrabbit.oak.commons.PathUtils;
import org.apache.jackrabbit.oak.namepath.impl.GlobalNameMapper;
import org.apache.jackrabbit.oak.namepath.NamePathMapper;
import org.apache.jackrabbit.oak.namepath.impl.NamePathMapperImpl;
import org.apache.jackrabbit.oak.plugins.tree.factories.RootFactory;
import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
import org.apache.jackrabbit.oak.spi.commit.Observer;
import org.apache.jackrabbit.oak.spi.state.NodeState;
import org.apache.jackrabbit.oak.commons.PerfLogger;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Base class for {@code Observer} instances that group changes
 * by node instead of tracking them down to individual properties.
 */
public abstract class NodeObserver implements Observer {

    private static final PerfLogger PERF_LOGGER = new PerfLogger(
            LoggerFactory.getLogger(NodeObserver.class.getName()
                    + ".perf"));
    private static final Logger LOG = LoggerFactory.getLogger(NodeObserver.class);

    private final String path;
    private final Set<String> propertyNames = new HashSet<>();

    private NodeState previousRoot;

    /**
     * Create a new instance for observing the given path.
     * @param path
     * @param propertyNames  names of properties to report even without a change
     */
    protected NodeObserver(String path, String... propertyNames) {
        this.path = path;
        addAll(this.propertyNames, propertyNames);
    }

    /**
     * A node at {@code path} has been added.
     * @param path       Path of the added node.
     * @param added      Names of the added properties.
     * @param deleted    Names of the deleted properties.
     * @param changed    Names of the changed properties.
     * @param properties Properties as specified in the constructor
     * @param commitInfo commit info associated with this change.
     */
    protected abstract void added(
            @NotNull String path,
            @NotNull Set<String> added,
            @NotNull Set<String> deleted,
            @NotNull Set<String> changed,
            @NotNull Map<String, String> properties,
            @NotNull CommitInfo commitInfo);

    /**
     * A node at {@code path} has been deleted.
     * @param path       Path of the deleted node.
     * @param added      Names of the added properties.
     * @param deleted    Names of the deleted properties.
     * @param changed    Names of the changed properties.
     * @param properties Properties as specified in the constructor
     * @param commitInfo commit info associated with this change.
     */
    protected abstract void deleted(
            @NotNull String path,
            @NotNull Set<String> added,
            @NotNull Set<String> deleted,
            @NotNull Set<String> changed,
            @NotNull Map<String, String> properties,
            @NotNull CommitInfo commitInfo);

    /**
     * A node at {@code path} has been changed.
     * @param path       Path of the changed node.
     * @param added      Names of the added properties.
     * @param deleted    Names of the deleted properties.
     * @param changed    Names of the changed properties.
     * @param properties Properties as specified in the constructor
     * @param commitInfo commit info associated with this change.
     */
    protected abstract void changed(
            @NotNull String path,
            @NotNull Set<String> added,
            @NotNull Set<String> deleted,
            @NotNull Set<String> changed,
            @NotNull Map<String, String> properties,
            @NotNull CommitInfo commitInfo);

    @Override
    public void contentChanged(@NotNull NodeState root, @NotNull CommitInfo info) {
        if (previousRoot != null) {
            try {
                long start = PERF_LOGGER.start();
                NamePathMapper namePathMapper = new NamePathMapperImpl(
                        new GlobalNameMapper(RootFactory.createReadOnlyRoot(root)));

                Set<String> oakPropertyNames = new HashSet<>();
                for (String name : propertyNames) {
                    String oakName = namePathMapper.getOakNameOrNull(name);
                    if (oakName == null) {
                        LOG.warn("Ignoring invalid property name: {}", name);
                    } else {
                        oakPropertyNames.add(oakName);
                    }
                }

                NodeState before = previousRoot;
                NodeState after = root;
                EventHandler handler = new FilteredHandler(
                        VISIBLE_FILTER,
                        new NodeEventHandler("/", info, namePathMapper, oakPropertyNames));

                String oakPath = namePathMapper.getOakPath(path);
                if (oakPath == null) {
                    LOG.warn("Cannot listen for changes on invalid path: {}", path);
                    return;
                }

                for (String oakName : PathUtils.elements(oakPath)) {
                    before = before.getChildNode(oakName);
                    after = after.getChildNode(oakName);
                    handler = handler.getChildHandler(oakName, before, after);
                }

                EventGenerator generator = new EventGenerator(before, after, handler);
                while (!generator.isDone()) {
                    generator.generate();
                }
                PERF_LOGGER.end(start, 100,
                        "Generated events (before: {}, after: {})",
                        previousRoot, root);
            } catch (Exception e) {
                LOG.warn("Error while dispatching observation events", e);
            }
        }

        previousRoot = root;
    }

    private enum EventType {ADDED, DELETED, CHANGED}

    private class NodeEventHandler extends DefaultEventHandler {
        private final String path;
        private final CommitInfo commitInfo;
        private final NamePathMapper namePathMapper;
        private final Set<String> propertyNames;
        private final EventType eventType;
        private final Set<String> added = new HashSet<>();
        private final Set<String> deleted = new HashSet<>();
        private final Set<String> changed = new HashSet<>();

        public NodeEventHandler(String path, CommitInfo commitInfo, NamePathMapper namePathMapper,
                Set<String> propertyNames) {
            this.path = path;
            this.commitInfo = commitInfo;
            this.namePathMapper = namePathMapper;
            this.propertyNames = propertyNames;
            this.eventType = EventType.CHANGED;
        }

        private NodeEventHandler(NodeEventHandler parent, String name, EventType eventType) {
            this.path = "/".equals(parent.path) ? '/' + name : parent.path + '/' + name;
            this.commitInfo = parent.commitInfo;
            this.namePathMapper = parent.namePathMapper;
            this.propertyNames = parent.propertyNames;
            this.eventType = eventType;
        }

        @Override
        public void leave(NodeState before, NodeState after) {
            switch (eventType) {
                case ADDED:
                    added(namePathMapper.getJcrPath(path), added, deleted, changed,
                            collectProperties(after), commitInfo);
                    break;
                case DELETED:
                    deleted(namePathMapper.getJcrPath(path), added, deleted, changed,
                            collectProperties(before), commitInfo);
                    break;
                case CHANGED:
                    if (!added.isEmpty() || ! deleted.isEmpty() || !changed.isEmpty()) {
                        changed(namePathMapper.getJcrPath(path), added, deleted, changed,
                                collectProperties(after), commitInfo);
                    }
                    break;
            }
        }

        private Map<String, String> collectProperties(NodeState node) {
            Map<String, String> properties = new HashMap<>();
            for (String name : propertyNames) {
                PropertyState p = node.getProperty(name);
                if (p != null && !p.isArray()) {
                    properties.put(name, p.getValue(Type.STRING));
                }
            }
            return properties;
        }

        @Override
        public EventHandler getChildHandler(String name, NodeState before, NodeState after) {
            if (!before.exists()) {
                return new NodeEventHandler(this, name, EventType.ADDED);
            } else if (!after.exists()) {
                return new NodeEventHandler(this, name, EventType.DELETED);
            } else {
                return new NodeEventHandler(this, name, EventType.CHANGED);
            }
        }

        @Override
        public void propertyAdded(PropertyState after) {
            added.add(namePathMapper.getJcrName(after.getName()));
        }

        @Override
        public void propertyChanged(PropertyState before, PropertyState after) {
            changed.add(namePathMapper.getJcrName(after.getName()));
        }

        @Override
        public void propertyDeleted(PropertyState before) {
            deleted.add(namePathMapper.getJcrName(before.getName()));
        }

    }
}
