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

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.apache.jackrabbit.oak.api.CommitFailedException;
import org.apache.jackrabbit.oak.commons.collections.IterableUtils;
import org.apache.jackrabbit.oak.plugins.document.AbstractDocumentNodeState;
import org.apache.jackrabbit.oak.plugins.document.Collection;
import org.apache.jackrabbit.oak.plugins.document.DocumentMKBuilderProvider;
import org.apache.jackrabbit.oak.plugins.document.DocumentNodeStore;
import org.apache.jackrabbit.oak.plugins.document.NodeDocument;
import org.apache.jackrabbit.oak.plugins.document.Path;
import org.apache.jackrabbit.oak.plugins.document.Revision;
import org.apache.jackrabbit.oak.plugins.document.RevisionVector;
import org.apache.jackrabbit.oak.plugins.document.bundlor.BundledTypesRegistry;
import org.apache.jackrabbit.oak.plugins.document.bundlor.BundlingConfigHandler;
import org.apache.jackrabbit.oak.plugins.document.init.BundlingConfigInitializer;
import org.apache.jackrabbit.oak.plugins.document.util.Utils;
import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore;
import org.apache.jackrabbit.oak.InitialContent;
import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
import org.apache.jackrabbit.oak.spi.commit.EmptyHook;
import org.apache.jackrabbit.oak.spi.filter.PathFilter;
import org.apache.jackrabbit.oak.spi.state.EqualsDiff;
import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
import org.apache.jackrabbit.oak.spi.state.NodeStateUtils;
import org.apache.jackrabbit.oak.spi.state.NodeStore;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;

import static org.apache.jackrabbit.JcrConstants.JCR_PRIMARYTYPE;
import static org.apache.jackrabbit.oak.plugins.document.secondary.SecondaryStoreObserverTest.create;
import static org.apache.jackrabbit.oak.plugins.document.secondary.SecondaryStoreObserverTest.documentState;
import static org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState.EMPTY_NODE;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;

public class SecondaryStoreCacheTest {
    private final List<String> empty = Collections.emptyList();
    @Rule
    public DocumentMKBuilderProvider builderProvider = new DocumentMKBuilderProvider();

    private DocumentNodeStore primary;
    private NodeStore secondary;

    @Before
    public void setUp() throws IOException {
        primary = builderProvider.newBuilder().getNodeStore();
        secondary = new MemoryNodeStore();
    }

    @Test
    public void basicTest() throws Exception{
        SecondaryStoreCache cache = createCache(new PathFilter(List.of("/a"), empty));

        NodeBuilder nb = primary.getRoot().builder();
        create(nb, "/a/b", "/a/c", "/x/y/z");
        merge(nb);

        RevisionVector rv1 = new RevisionVector(new Revision(1,0,1));
        RevisionVector rv2 = new RevisionVector(new Revision(1,0,3));
        assertNull(cache.getDocumentNodeState(Path.fromString("/a/b"), rv1, rv2));
        assertNull(cache.getDocumentNodeState(Path.fromString("/x"), rv1, rv2));
    }

    @Test
    public void updateAndReadAtReadRev() throws Exception{
        SecondaryStoreCache cache = createCache(new PathFilter(List.of("/a"), empty));

        NodeBuilder nb = primary.getRoot().builder();
        create(nb, "/a/b", "/a/c", "/x/y/z");
        AbstractDocumentNodeState r1 = merge(nb);

        //Update some other part of tree i.e. which does not change lastRev for /a/c
        nb = primary.getRoot().builder();
        create(nb, "/a/e/d");
        AbstractDocumentNodeState r2 = merge(nb);

        //Lookup should work fine
        AbstractDocumentNodeState a_r2 = documentState(r2, "/a/c");
        AbstractDocumentNodeState result
                = cache.getDocumentNodeState(Path.fromString("/a/c"), r2.getRootRevision(), a_r2.getLastRevision());
        assertTrue(EqualsDiff.equals(a_r2, result));

        //Child docs should only have lastRev and not root rev
        assertTrue(result.hasProperty(DelegatingDocumentNodeState.PROP_LAST_REV));
        assertFalse(result.hasProperty(DelegatingDocumentNodeState.PROP_REVISION));

        //Root doc would have both meta props
        assertTrue(secondary.getRoot().hasProperty(DelegatingDocumentNodeState.PROP_LAST_REV));
        assertTrue(secondary.getRoot().hasProperty(DelegatingDocumentNodeState.PROP_REVISION));

        nb = primary.getRoot().builder();
        nb.child("a").child("c").remove();
        AbstractDocumentNodeState r3 = merge(nb);

        //Now look from older revision
        result = cache.getDocumentNodeState(Path.fromString("/a/c"), r3.getRootRevision(), a_r2.getLastRevision());

        //now as its not visible from head it would not be visible
        assertNull(result);
    }

    @Test
    public void updateAndReadAtPrevRevision() throws Exception {
        SecondaryStoreCache cache = createCache(new PathFilter(List.of("/a"), empty));

        NodeBuilder nb = primary.getRoot().builder();
        create(nb, "/a/b", "/a/c");
        AbstractDocumentNodeState r0 = merge(nb);
        AbstractDocumentNodeState a_c_0 = documentState(primary.getRoot(), "/a/c");

        //Update some other part of tree i.e. which does not change lastRev for /a/c
        nb = primary.getRoot().builder();
        create(nb, "/a/c/d");
        AbstractDocumentNodeState r1 = merge(nb);
        AbstractDocumentNodeState a_c_1 = documentState(primary.getRoot(), "/a/c");

        AbstractDocumentNodeState result
                = cache.getDocumentNodeState(Path.fromString("/a/c"), r1.getRootRevision(), a_c_1.getLastRevision());
        assertTrue(EqualsDiff.equals(a_c_1, result));

        //Read from older revision
        result = cache.getDocumentNodeState(Path.fromString("/a/c"), r0.getRootRevision(), a_c_0.getLastRevision());
        assertTrue(EqualsDiff.equals(a_c_0, result));
    }

    @Test
    public void binarySearch() throws Exception{
        SecondaryStoreCache cache = createCache(new PathFilter(List.of("/a"), empty));

        List<AbstractDocumentNodeState> roots = new ArrayList<>();
        List<RevisionVector> revs = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            NodeBuilder nb = primary.getRoot().builder();
            create(nb, "/a/b"+i);
            AbstractDocumentNodeState r = merge(nb);
            roots.add(r);
            revs.add(r.getRootRevision());
        }

        AbstractDocumentNodeState[] rootsArr = IterableUtils.toArray(roots, AbstractDocumentNodeState.class);

        Collections.shuffle(revs);
        for (RevisionVector rev : revs){
            AbstractDocumentNodeState result = SecondaryStoreCache.findMatchingRoot(rootsArr, rev);
            assertNotNull(result);
            assertEquals(rev, result.getRootRevision());
        }

        NodeBuilder nb = primary.getRoot().builder();
        create(nb, "/a/m");
        AbstractDocumentNodeState r = merge(nb);
        AbstractDocumentNodeState result = SecondaryStoreCache.findMatchingRoot(rootsArr, r.getRootRevision());
        assertNull(result);

    }

    @Test
    public void readWithSecondaryLagging() throws Exception{
        PathFilter pathFilter = new PathFilter(List.of("/a"), empty);
        SecondaryStoreCache cache = createBuilder(pathFilter).buildCache();
        SecondaryStoreObserver observer = createBuilder(pathFilter).buildObserver(cache);

        NodeBuilder nb = primary.getRoot().builder();
        create(nb, "/a/b", "/a/c");
        AbstractDocumentNodeState r0 = merge(nb);
        AbstractDocumentNodeState a_c_0 = documentState(primary.getRoot(), "/a/c");

        observer.contentChanged(r0, CommitInfo.EMPTY);

        AbstractDocumentNodeState result = cache.getDocumentNodeState(Path.fromString("/a/c"), r0.getRootRevision(), a_c_0
                .getLastRevision());
        assertTrue(EqualsDiff.equals(a_c_0, result));

        //Make change in some other part of tree i.e. /a/c is unmodified
        nb = primary.getRoot().builder();
        create(nb, "/a/e");
        AbstractDocumentNodeState r1 = merge(nb);

        //Change is yet not pushed to secondary i.e. observer not invoked
        //but lookup with latest root should still work fine if lastRev matches
        result = cache.getDocumentNodeState(Path.fromString("/a/c"), r1.getRootRevision(), a_c_0
                .getLastRevision());
        assertTrue(EqualsDiff.equals(a_c_0, result));

        //Change which is not pushed would though not be visible
        AbstractDocumentNodeState a_e_1 = documentState(primary.getRoot(), "/a/e");
        result = cache.getDocumentNodeState(Path.fromString("/a/e"), r1.getRootRevision(), a_e_1
                .getLastRevision());
        assertNull(result);
    }

    @Test
    public void isCached() throws Exception{
        SecondaryStoreCache cache = createCache(new PathFilter(List.of("/a"), empty));

        assertTrue(cache.isCached(Path.fromString("/a")));
        assertTrue(cache.isCached(Path.fromString("/a/b")));
        assertFalse(cache.isCached(Path.fromString("/x")));
    }

    @Test
    public void bundledNodes() throws Exception{
        SecondaryStoreCache cache = createCache(new PathFilter(List.of("/"), empty));
        primary.setNodeStateCache(cache);

        NodeBuilder builder = primary.getRoot().builder();
        new InitialContent().initialize(builder);
        BundlingConfigInitializer.INSTANCE.initialize(builder);
        merge(builder);

        BundledTypesRegistry registry = BundledTypesRegistry.from(NodeStateUtils.getNode(primary.getRoot(),
                BundlingConfigHandler.CONFIG_PATH));
        assertNotNull("DocumentBundling not found to be enabled for nt:file",
                registry.getBundlor(newNode("nt:file").getNodeState()));

        //1. Create a file node
        builder = primary.getRoot().builder();
        NodeBuilder fileNode = newNode("nt:file");
        fileNode.child("jcr:content").setProperty("jcr:data", "foo");
        builder.child("test").setChildNode("book.jpg", fileNode.getNodeState());
        merge(builder);

        //2. Assert that bundling is working
        assertNull(getNodeDocument("/test/book.jpg/jcr:content"));

        //3. Now update the file node
        builder = primary.getRoot().builder();
        builder.getChildNode("test").getChildNode("book.jpg").getChildNode("jcr:content").setProperty("foo", "bar");
        merge(builder);
    }

    private SecondaryStoreCache createCache(PathFilter pathFilter){
        SecondaryStoreBuilder builder = createBuilder(pathFilter);
        builder.metaPropNames(DocumentNodeStore.META_PROP_NAMES);
        SecondaryStoreCache cache = builder.buildCache();
        SecondaryStoreObserver observer = builder.buildObserver(cache);
        primary.addObserver(observer);

        return cache;
    }

    private static NodeBuilder newNode(String typeName){
        NodeBuilder builder = EMPTY_NODE.builder();
        builder.setProperty(JCR_PRIMARYTYPE, typeName);
        return builder;
    }

    private NodeDocument getNodeDocument(String path) {
        return primary.getDocumentStore().find(Collection.NODES, Utils.getIdFromPath(path));
    }

    private SecondaryStoreBuilder createBuilder(PathFilter pathFilter) {
        return new SecondaryStoreBuilder(secondary).pathFilter(pathFilter);
    }

    private AbstractDocumentNodeState merge(NodeBuilder nb) throws CommitFailedException {
        return (AbstractDocumentNodeState) primary.merge(nb, EmptyHook.INSTANCE, CommitInfo.EMPTY);
    }

}