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

import java.util.List;

import org.apache.jackrabbit.guava.common.base.Supplier;
import org.apache.jackrabbit.guava.common.cache.Cache;

import org.apache.jackrabbit.oak.plugins.document.util.Utils;
import org.apache.jackrabbit.oak.stats.Clock;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.LoggerFactory;

import static org.apache.jackrabbit.guava.common.cache.CacheBuilder.newBuilder;
import static org.apache.jackrabbit.guava.common.collect.ImmutableList.of;

/**
 * Resolves the commit value for a given change revision on a document.
 */
final class CachingCommitValueResolver implements CommitValueResolver {

    private static final List<String> COMMIT_ROOT_OR_REVISIONS
            = of(NodeDocument.COMMIT_ROOT, NodeDocument.REVISIONS);

    private final Cache<Revision, String> commitValueCache;

    private final int cacheSize;

    private final Supplier<RevisionVector> sweepRevisions;

    CachingCommitValueResolver(int cacheSize, Supplier<RevisionVector> sweepRevisions) {
        this.commitValueCache = newBuilder().maximumSize(cacheSize).build();
        this.cacheSize = cacheSize;
        this.sweepRevisions = sweepRevisions;
    }

    CommitValueResolver withEmptyCommitValueCache(boolean enable,
                                                  Clock clock,
                                                  long maxAgeMillis) {
        if (enable) {
            return new CommitValueResolver() {

                private final Cache<Revision, String> emptyCommitValueCache
                        = newBuilder().maximumSize(cacheSize).build();

                @Override
                public String resolve(@NotNull Revision changeRevision,
                                      @NotNull NodeDocument doc) {
                    // check cache first
                    String value = commitValueCache.getIfPresent(changeRevision);
                    if (value != null) {
                        return value;
                    }
                    // check negative cache
                    if (emptyCommitValueCache.getIfPresent(changeRevision) != null) {
                        return null;
                    }
                    value = CachingCommitValueResolver.this.resolve(changeRevision, doc);
                    if (value == null && isOld(changeRevision, clock, maxAgeMillis)) {
                        // remember
                        emptyCommitValueCache.put(changeRevision, "");
                    }
                    return value;
                }

                private boolean isOld(Revision r, Clock c, long maxAgeMillis) {
                    return r.getTimestamp() + maxAgeMillis < c.getTime();
                }
            };
        } else {
            return this;
        }
    }

    @Override
    public String resolve(@NotNull Revision changeRevision,
                          @NotNull NodeDocument doc) {
        // check cache first
        String value = commitValueCache.getIfPresent(changeRevision);
        if (value != null) {
            return value;
        }
        // need to determine the commit value
        doc = resolveDocument(doc, changeRevision);
        if (doc == null) {
            // the document including its history does not contain a change
            // for the given revision
            return null;
        }
        // at this point 'doc' is guaranteed to have a local entry
        // for the given change revision
        if (sweepRevisions.get().isRevisionNewer(changeRevision)) {
            // change revision is newer than sweep revision
            // resolve the commit value without any short cuts
            value = doc.resolveCommitValue(changeRevision);
        } else {
            // change revision is equal or older than sweep revision
            // there are different cases:
            // - doc is a main document and we have a guarantee that a
            //   potential branch commit is marked accordingly
            // - doc is a split document and the revision is guaranteed
            //   to be committed. the remaining question is whether the
            //   revision is from a branch commit
            NodeDocument.SplitDocType sdt = doc.getSplitDocType();
            if (sdt == NodeDocument.SplitDocType.NONE) {
                // sweeper ensures that all changes on main document
                // before the sweep revision are properly marked with
                // branch commit entry if applicable
                if (doc.getLocalBranchCommits().contains(changeRevision)) {
                    // resolve the commit value the classic way
                    value = doc.resolveCommitValue(changeRevision);
                } else {
                    value = "c";
                }
            } else if (sdt == NodeDocument.SplitDocType.DEFAULT_NO_BRANCH) {
                // split document without branch commits, we don't have
                // to check the commit root, the commit value is always 'c'
                value = "c";
            } else {
                // some other split document type introduced
                // before Oak 1.8 and we don't know if this is a branch
                // commit. first try to resolve
                value = doc.resolveCommitValue(changeRevision);
                if (value == null) {
                    // then it must be a non-branch commit and the
                    // split document with the commit value was
                    // already garbage collected
                    value = "c";
                }
            }
        }
        if (Utils.isCommitted(value)) {
            // only cache committed states
            // e.g. branch commits may be merged later and
            // the commit value will change
            try {
                commitValueCache.put(changeRevision, value);
            } catch (RuntimeException re) {
                LoggerFactory.getLogger(getClass()).warn("resolve: RuntimeException with " +
                        changeRevision + " - " + value, re);
            }
        }
        return value;
    }

    /**
     * Resolves to the document that contains the change with the given
     * revision. If the given document contains a local change for the given
     * revision, then the passed document is returned. Otherwise this method
     * looks up previous documents and returns one with a change for the given
     * revision. This method returns {@code null} if neither the passed document
     * nor any of its previous documents contains a change for the given
     * revision.
     *
     * @param doc the document to resolve for the given change revision.
     * @param changeRevision the revision of a change.
     * @return the document with the change or {@code null} if there is no
     *      document with such a change.
     */
    @Nullable
    private NodeDocument resolveDocument(@NotNull NodeDocument doc,
                                         @NotNull Revision changeRevision) {
        // check if the document contains the change or we need to
        // look up previous documents for the actual change
        if (doc.getLocalCommitRoot().containsKey(changeRevision)
                || doc.getLocalRevisions().containsKey(changeRevision)) {
            return doc;
        }
        // find the previous document with this change
        // first check if there is a commit root entry for this revision
        NodeDocument d = null;
        for (String p : COMMIT_ROOT_OR_REVISIONS) {
            for (NodeDocument prev : doc.getPreviousDocs(p, changeRevision)) {
                d = prev;
                break;
            }
            if (d != null) {
                break;
            }
        }
        return d;
    }

}
