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

import static org.apache.jackrabbit.guava.common.collect.Lists.newArrayList;
import static org.apache.jackrabbit.guava.common.collect.Maps.newHashMap;
import static org.apache.jackrabbit.guava.common.collect.Maps.newLinkedHashMap;
import static org.apache.jackrabbit.oak.commons.PathUtils.elements;
import static org.apache.jackrabbit.oak.commons.PathUtils.getName;
import static org.apache.jackrabbit.oak.commons.PathUtils.getParentPath;
import static org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState.EMPTY_NODE;

import java.io.IOException;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.apache.jackrabbit.oak.commons.Buffer;
import org.apache.jackrabbit.oak.segment.file.GCNodeWriteMonitor;
import org.apache.jackrabbit.oak.segment.file.cancel.Canceller;
import org.apache.jackrabbit.oak.spi.blob.BlobStore;
import org.apache.jackrabbit.oak.spi.gc.GCMonitor;
import org.apache.jackrabbit.oak.spi.state.ChildNodeEntry;
import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
import org.apache.jackrabbit.oak.spi.state.NodeState;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
 * This compactor implementation is aware of the checkpoints in the repository.
 * It uses this information to further optimise the compaction result by
 * <ul>
 *     <li>Rebasing the checkpoints and subsequently the root on top of each other
 *     in chronological order. This results minimises the deltas that need to be
 *     processed and stored.</li>
 *     <li>Caching the compacted checkpoints and root states for deduplication should
 *     the same checkpoint or root state occur again in a later compaction retry cycle.</li>
 * </ul>
 */
public class CheckpointCompactor implements Compactor {
    @NotNull
    private final GCMonitor gcListener;

    @NotNull
    private final Map<NodeState, NodeState> cpCache = newHashMap();

    @NotNull
    private final ClassicCompactor compactor;

    @NotNull
    private final NodeWriter nodeWriter;

    private interface NodeWriter {
        @NotNull
        SegmentNodeState writeNode(@NotNull NodeState node, @Nullable Buffer stableId) throws IOException;
    }

    /**
     * Create a new instance based on the passed arguments.
     * @param gcListener listener receiving notifications about the garbage collection process
     * @param reader     segment reader used to read from the segments
     * @param writer     segment writer used to serialise to segments
     * @param blobStore  the blob store or {@code null} if none
     * @param compactionMonitor   notification call back for each compacted nodes,
     *                            properties, and binaries
     */
    public CheckpointCompactor(
            @NotNull GCMonitor gcListener,
            @NotNull SegmentReader reader,
            @NotNull SegmentWriter writer,
            @Nullable BlobStore blobStore,
            @NotNull GCNodeWriteMonitor compactionMonitor) {
        this.gcListener = gcListener;
        this.compactor = new ClassicCompactor(reader, writer, blobStore, compactionMonitor);
        this.nodeWriter = (node, stableId) -> {
            RecordId nodeId = writer.writeNode(node, stableId);
            return new SegmentNodeState(reader, writer, blobStore, nodeId);
        };
    }

    /**
     * Compact {@code uncompacted} on top of an optional {@code base}.
     * @param base         the base state to compact against
     * @param uncompacted  the uncompacted state to compact
     * @param onto         the state onto which to compact the change between {@code base} and
     *                     {@code uncompacted}
     * @return  compacted clone of {@code uncompacted} or {@code null} if cancelled.
     * @throws IOException
     */
    @Override
    @Nullable
    public SegmentNodeState compact(
        @NotNull NodeState base,
        @NotNull NodeState uncompacted,
        @NotNull NodeState onto,
        Canceller canceller
    ) throws IOException {
        // Collect a chronologically ordered list of roots for the uncompacted
        // state. This list consists of all checkpoints followed by the root.
        LinkedHashMap<String, NodeState> uncompactedRoots = collectRoots(uncompacted);

        // Compact the list of uncompacted roots to a list of compacted roots.
        LinkedHashMap<String, NodeState> compactedRoots = compact(
            getRoot(base),
            uncompactedRoots,
            getRoot(onto),
            canceller
        );

        if (compactedRoots == null) {
            return null;
        }

        // Build a compacted super root by replacing the uncompacted roots with
        // the compacted ones in the original node.
        NodeBuilder builder = uncompacted.builder();
        for (Entry<String, NodeState> compactedRoot : compactedRoots.entrySet()) {
            String path = compactedRoot.getKey();
            NodeState state = compactedRoot.getValue();
            NodeBuilder childBuilder = getChild(builder, getParentPath(path));
            childBuilder.setChildNode(getName(path), state);
        }

        return nodeWriter.writeNode(builder.getNodeState(), getStableIdBytes(uncompacted));
    }

    @Nullable
    private static Buffer getStableIdBytes(@NotNull NodeState node) {
        return node instanceof SegmentNodeState
            ? ((SegmentNodeState) node).getStableIdBytes()
            : null;
    }

    @NotNull
    private static NodeState getRoot(@NotNull NodeState node) {
        return node.hasChildNode("root")
            ? node.getChildNode("root")
            : EMPTY_NODE;
    }

    /**
     * Compact a list of uncompacted roots on top of base roots of the same key or
     * an empty node if none.
     */
    @Nullable
    private LinkedHashMap<String, NodeState> compact(
        @NotNull NodeState base,
        @NotNull LinkedHashMap<String, NodeState> uncompactedRoots,
        @NotNull NodeState onto,
        Canceller canceller
    ) throws IOException {
        LinkedHashMap<String, NodeState> compactedRoots = newLinkedHashMap();
        for (Entry<String, NodeState> uncompactedRoot : uncompactedRoots.entrySet()) {
            String path = uncompactedRoot.getKey();
            NodeState uncompacted = uncompactedRoot.getValue();
            Result result = compactWithCache(base, uncompacted, onto, path, canceller);
            if (result == null) {
                return null;
            }
            base = result.nextBefore;
            onto = result.nextOnto;
            compactedRoots.put(path, result.compacted);
        }
        return compactedRoots;
    }

    /**
     * Collect a chronologically ordered list of roots for the base and the uncompacted
     * state from a {@code superRoot}. This list consists of all checkpoints followed by
     * the root.
     */
    @NotNull
    private LinkedHashMap<String, NodeState> collectRoots(@Nullable NodeState superRoot) {
        LinkedHashMap<String, NodeState> roots = newLinkedHashMap();
        if (superRoot != null) {
            List<ChildNodeEntry> checkpoints = newArrayList(
                    superRoot.getChildNode("checkpoints").getChildNodeEntries());

            checkpoints.sort((cne1, cne2) -> {
                long c1 = cne1.getNodeState().getLong("created");
                long c2 = cne2.getNodeState().getLong("created");
                return Long.compare(c1, c2);
            });

            for (ChildNodeEntry checkpoint : checkpoints) {
                String name = checkpoint.getName();
                NodeState node = checkpoint.getNodeState();
                gcListener.info("found checkpoint {} created at {}.",
                    name, new Date(node.getLong("created")));
                roots.put("checkpoints/" + name + "/root", node.getChildNode("root"));
            }
            roots.put("root", superRoot.getChildNode("root"));
        }
        return roots;
    }

    @NotNull
    private static NodeBuilder getChild(NodeBuilder builder, String path) {
        for (String name : elements(path)) {
            builder = builder.getChildNode(name);
        }
        return builder;
    }

    private static class Result {
            final NodeState compacted;
            final NodeState nextBefore;
            final NodeState nextOnto;

            Result(@NotNull NodeState compacted, @NotNull NodeState nextBefore, @NotNull NodeState nextOnto) {
                this.compacted = compacted;
                this.nextBefore = nextBefore;
                this.nextOnto = nextOnto;
            }
        }

    /**
     * Compact {@code after} against {@code before} on top of {@code onto} unless
     * {@code after} has been compacted before and is found in the cache. In this
     * case the cached version of the previously compacted {@code before} is returned.
     */
    @Nullable
    private Result compactWithCache(
        @NotNull NodeState before,
        @NotNull NodeState after,
        @NotNull NodeState onto,
        @NotNull String path,
        Canceller canceller
    ) throws IOException {
        gcListener.info("compacting {}.", path);
        NodeState compacted = cpCache.get(after);
        if (compacted == null) {
            compacted = compactor.compact(before, after, onto, canceller);
            if (compacted == null) {
                return null;
            } else {
                cpCache.put(after, compacted);
                return new Result(compacted, after, compacted);
            }
        } else {
            gcListener.info("found {} in cache.", path);
            return new Result(compacted, before, onto);
        }
    }

}
