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

import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Properties;
import java.util.Set;

import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.io.Files;
import org.apache.felix.inventory.Format;
import org.apache.jackrabbit.oak.api.CommitFailedException;
import org.apache.jackrabbit.oak.api.Type;
import org.apache.jackrabbit.oak.commons.PathUtils;
import org.apache.jackrabbit.oak.plugins.index.AsyncIndexUpdate;
import org.apache.jackrabbit.oak.plugins.index.IndexConstants;
import org.apache.jackrabbit.oak.plugins.index.IndexEditorProvider;
import org.apache.jackrabbit.oak.plugins.index.IndexUpdateCallback;
import org.apache.jackrabbit.oak.plugins.index.IndexUpdateProvider;
import org.apache.jackrabbit.oak.plugins.index.inventory.IndexDefinitionPrinter;
import org.apache.jackrabbit.oak.plugins.index.property.PropertyIndexEditorProvider;
import org.apache.jackrabbit.oak.plugins.index.property.PropertyIndexLookup;
import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore;
import org.apache.jackrabbit.oak.plugins.memory.PropertyValues;
import org.apache.jackrabbit.oak.query.NodeStateNodeTypeInfoProvider;
import org.apache.jackrabbit.oak.query.QueryEngineSettings;
import org.apache.jackrabbit.oak.query.ast.NodeTypeInfo;
import org.apache.jackrabbit.oak.query.ast.NodeTypeInfoProvider;
import org.apache.jackrabbit.oak.query.ast.SelectorImpl;
import org.apache.jackrabbit.oak.query.index.FilterImpl;
import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
import org.apache.jackrabbit.oak.spi.commit.Editor;
import org.apache.jackrabbit.oak.spi.commit.EditorHook;
import org.apache.jackrabbit.oak.spi.commit.EmptyHook;
import org.apache.jackrabbit.oak.spi.query.Filter;
import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
import org.apache.jackrabbit.oak.spi.state.NodeState;
import org.apache.jackrabbit.oak.spi.state.NodeStateUtils;
import org.apache.jackrabbit.oak.spi.state.NodeStore;
import org.jetbrains.annotations.NotNull;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;

import static com.google.common.base.Charsets.UTF_8;
import static com.google.common.collect.ImmutableSet.of;
import static java.util.Arrays.asList;
import static org.apache.jackrabbit.JcrConstants.NT_BASE;
import static org.apache.jackrabbit.oak.InitialContentHelper.INITIAL_CONTENT;
import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.ASYNC_PROPERTY_NAME;
import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.INDEX_DEFINITIONS_NAME;
import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.REINDEX_COUNT;
import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.TYPE_DISABLED;
import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.TYPE_PROPERTY_NAME;
import static org.apache.jackrabbit.oak.plugins.index.IndexUtils.createIndexDefinition;
import static org.apache.jackrabbit.oak.plugins.index.importer.AsyncIndexerLock.NOOP_LOCK;
import static org.apache.jackrabbit.oak.plugins.index.importer.IndexDefinitionUpdater.INDEX_DEFINITIONS_JSON;
import static org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState.EMPTY_NODE;
import static org.junit.Assert.*;
import static org.mockito.Mockito.mock;

public class IndexImporterTest {

    @Rule
    public final TemporaryFolder temporaryFolder = new TemporaryFolder(new File("target"));

    private NodeStore store = new MemoryNodeStore();
    private IndexEditorProvider provider = new PropertyIndexEditorProvider();

    @Test(expected = IllegalArgumentException.class)
    public void importIndex_NoMeta() throws Exception{
        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, NOOP_LOCK);
    }

    @Test(expected = NullPointerException.class)
    public void lostCheckpoint() throws Exception{
        IndexerInfo info = new IndexerInfo(temporaryFolder.getRoot(), "unknown-checkpoint");
        info.save();
        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, NOOP_LOCK);
    }

    @Test
    public void switchLanes() throws Exception{
        NodeBuilder builder = store.getRoot().builder();
        builder.child("idx-a").setProperty("type", "property");

        builder.child("idx-b").setProperty("type", "property");
        builder.child("idx-b").setProperty("async", "async");

        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        createIndexDirs("/idx-a", "/idx-b");

        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, NOOP_LOCK);
        importer.switchLanes();

        NodeState idxa = NodeStateUtils.getNode(store.getRoot(), "/idx-a");
        assertEquals(AsyncLaneSwitcher.getTempLaneName(IndexImporter.ASYNC_LANE_SYNC), idxa.getString(ASYNC_PROPERTY_NAME));

        NodeState idxb = NodeStateUtils.getNode(store.getRoot(), "/idx-b");
        assertEquals(AsyncLaneSwitcher.getTempLaneName("async"), idxb.getString(ASYNC_PROPERTY_NAME));
    }

    @Test(expected = NullPointerException.class)
    public void importData_NoProvider() throws Exception{
        NodeBuilder builder = store.getRoot().builder();
        builder.child("idx-a").setProperty("type", "property");

        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        createIndexDirs("/idx-a");

        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, NOOP_LOCK);
        importer.switchLanes();
        importer.importIndexData();
    }

    @Test
    public void importData_CallbackInvoked() throws Exception{
        NodeBuilder builder = store.getRoot().builder();
        builder.child("idx-a").setProperty("type", "property");
        builder.child("idx-a").setProperty("foo", "bar");

        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        createIndexDirs("/idx-a");

        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, NOOP_LOCK);

        IndexImporterProvider provider = new IndexImporterProvider() {
            @Override
            public void importIndex(NodeState root, NodeBuilder defn, File indexDir) {
                assertEquals("bar", defn.getString("foo"));
                assertEquals("idx-a", indexDir.getName());
                defn.setProperty("imported", true);
            }

            @Override
            public String getType() {
                return "property";
            }
        };
        importer.addImporterProvider(provider);
        importer.switchLanes();
        importer.importIndexData();

        assertTrue(store.getRoot().getChildNode("idx-a").getBoolean("imported"));
    }

    @Test
    public void importData_UpdatedIndexDefinition() throws Exception{
        NodeBuilder builder = store.getRoot().builder();
        builder.child("idx-a").setProperty("type", "property");
        builder.child("idx-a").setProperty("foo", "bar");

        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        createIndexDirs("/idx-a");

        //We remove foo property here
        builder = store.getRoot().builder();
        builder.child("idx-a").removeProperty("foo");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, NOOP_LOCK);

        IndexImporterProvider provider = new IndexImporterProvider() {
            @Override
            public void importIndex(NodeState root, NodeBuilder defn, File indexDir) {
                //Foo property should be set by virtue of import from json
                assertEquals("bar", defn.getString("foo"));
            }

            @Override
            public String getType() {
                return "property";
            }
        };
        importer.addImporterProvider(provider);
        importer.switchLanes();
        importer.importIndexData();
    }

    @Test
    public void importData_IncrementalUpdate() throws Exception{
        NodeBuilder builder = store.getRoot().builder();
        createIndexDefinition(builder.child(INDEX_DEFINITIONS_NAME),
                "fooIndex", true, false, ImmutableSet.of("foo"), null)
                .setProperty(ASYNC_PROPERTY_NAME, "async");
        builder.child("a").setProperty("foo", "abc");
        builder.child("b").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        new AsyncIndexUpdate("async", store, provider).run();

        String checkpoint = createIndexDirs("/oak:index/fooIndex");

        builder = store.getRoot().builder();
        builder.child("c").setProperty("foo", "abc");
        builder.child("d").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        new AsyncIndexUpdate("async", store, provider).run();

        FilterImpl f = createFilter(store.getRoot(), NT_BASE);
        PropertyIndexLookup lookup = new PropertyIndexLookup(store.getRoot());
        assertEquals(of("a", "b", "c", "d"), find(lookup, "foo", "abc", f));

        IndexImporterProvider importerProvider = new IndexImporterProvider() {
            @Override
            public void importIndex(NodeState root, NodeBuilder defn, File indexDir) {
                assertEquals("fooIndex", indexDir.getName());
                assertEquals(2, defn.getProperty(REINDEX_COUNT).getValue(Type.LONG).longValue());
                defn.getChildNode(IndexConstants.INDEX_CONTENT_NODE_NAME).remove();

                NodeState cpState = store.retrieve(checkpoint);
                NodeState indexData = NodeStateUtils.getNode(cpState, "/oak:index/fooIndex/:index");
                defn.setChildNode(IndexConstants.INDEX_CONTENT_NODE_NAME, indexData);
            }

            @Override
            public String getType() {
                return "property";
            }
        };

        builder = store.getRoot().builder();
        builder.child("e").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, NOOP_LOCK);
        importer.addImporterProvider(importerProvider);
        importer.addImporterProvider(importerProvider);

        importer.importIndex();

        NodeState idx = store.getRoot().getChildNode("oak:index").getChildNode("fooIndex");
        assertEquals("async", idx.getString("async"));

        lookup = new PropertyIndexLookup(store.getRoot());
        //It would not pickup /e as thats not yet indexed as part of last checkpoint
        assertEquals(of("a", "b", "c", "d"), find(lookup, "foo", "abc", f));
        assertNull(store.retrieve(checkpoint));
    }

    @Test
    public void importIndex_newIndex() throws Exception{
        NodeBuilder builder = store.getRoot().builder();
        builder.child("oak:index");
        builder.child("a").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        String json = "{\"/oak:index/fooIndex\": {\n" +
                "    \"reindexCount\": 1,\n" +
                "    \"reindex\": false,\n" +
                "    \"type\": \"property\",\n" +
                "    \"async\" : \"async\",\n" +
                "    \"propertyNames\": [\"foo\"],\n" +
                "    \"jcr:primaryType\": \"oak:QueryIndexDefinition\"\n" +
                "  }\n" +
                "}";


        File indexFolder = temporaryFolder.getRoot();

        //Create checkpoint file
        String checkpoint = store.checkpoint(1000000);
        IndexerInfo info = new IndexerInfo(indexFolder, checkpoint);
        info.save();

        //Create index definitions json
        Files.write(json, new File(indexFolder, INDEX_DEFINITIONS_JSON), UTF_8);

        createIndexFolder(indexFolder, "/oak:index/fooIndex");

        //Prepare importer
        IndexImporterProvider importerProvider = new IndexImporterProvider() {
            @Override
            public void importIndex(NodeState root, NodeBuilder defn, File indexDir)
                    throws CommitFailedException {
                NodeState fooIndex = getFooIndexNodeState();
                defn.setChildNode(IndexConstants.INDEX_CONTENT_NODE_NAME,
                        fooIndex.getChildNode(":index"));
            }

            @Override
            public String getType() {
                return "property";
            }
        };

        IndexImporter importer = new IndexImporter(store, indexFolder, provider, NOOP_LOCK);
        importer.addImporterProvider(importerProvider);

        //Add some more indexable data
        builder = store.getRoot().builder();
        builder.child("c").setProperty("foo", "abc");
        builder.child("d").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        new AsyncIndexUpdate("async", store, provider).run();

        //Now perform import
        importer.importIndex();

        FilterImpl f = createFilter(store.getRoot(), NT_BASE);
        PropertyIndexLookup lookup = new PropertyIndexLookup(store.getRoot());
        assertEquals(of("a", "c", "d"), find(lookup, "foo", "abc", f));

        NodeState idx = store.getRoot().getChildNode("oak:index").getChildNode("fooIndex");
        assertEquals(2, idx.getLong("reindexCount"));
    }

    @Test
    public void importData_DisabledIndexes() throws Exception{
        NodeBuilder builder = store.getRoot().builder();
        NodeBuilder idxa = createIndexDefinition(builder.child(INDEX_DEFINITIONS_NAME),
                "fooIndex", true, false, ImmutableSet.of("foo"), null);
        idxa.setProperty(ASYNC_PROPERTY_NAME, "async");
        idxa.setProperty(IndexConstants.SUPERSEDED_INDEX_PATHS, asList("/oak:index/barIndex"), Type.STRINGS);

        builder.child("a").setProperty("foo", "abc");
        builder.child("b").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        new AsyncIndexUpdate("async", store, provider).run();

        String checkpoint = createIndexDirs("/oak:index/fooIndex");

        builder = store.getRoot().builder();

        createIndexDefinition(builder.child(INDEX_DEFINITIONS_NAME),
                "barIndex", true, false, ImmutableSet.of("foo"), null);

        builder.child("c").setProperty("foo", "abc");
        builder.child("d").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        new AsyncIndexUpdate("async", store, provider).run();

        IndexImporterProvider importerProvider = new IndexImporterProvider() {
            @Override
            public void importIndex(NodeState root, NodeBuilder defn, File indexDir) {
            }

            @Override
            public String getType() {
                return "property";
            }
        };

        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, NOOP_LOCK);
        importer.addImporterProvider(importerProvider);

        importer.importIndex();

        NodeState idx = store.getRoot().getChildNode("oak:index").getChildNode("barIndex");
        assertEquals(TYPE_DISABLED, idx.getString(TYPE_PROPERTY_NAME));
    }

    private NodeState getFooIndexNodeState() throws CommitFailedException {
        NodeState root = INITIAL_CONTENT;
        // Add index definition
        NodeBuilder builder = root.builder();
        NodeBuilder index = createIndexDefinition(builder.child(INDEX_DEFINITIONS_NAME), "fooIndex",
                true, false, ImmutableSet.of("foo"), null);
        builder.child("a").setProperty("foo", "abc");
        NodeState after = builder.getNodeState();
        EditorHook hook = new EditorHook(
                new IndexUpdateProvider(new PropertyIndexEditorProvider()));
        NodeState indexed = hook.processCommit(EMPTY_NODE, after, CommitInfo.EMPTY);
        return indexed.getChildNode("oak:index").getChildNode("fooIndex");
    }

    @Test
    public void laneName() throws Exception{
        NodeBuilder builder = EMPTY_NODE.builder();
        builder.setProperty(IndexConstants.ASYNC_PROPERTY_NAME, "async");
        assertEquals("async", IndexImporter.getAsyncLaneName("foo", builder.getNodeState()));

        builder = EMPTY_NODE.builder();
        builder.setProperty(IndexConstants.ASYNC_PROPERTY_NAME, asList("async", "nrt"), Type.STRINGS);
        assertEquals("async", IndexImporter.getAsyncLaneName("foo", builder.getNodeState()));

        builder = EMPTY_NODE.builder();
        builder.setProperty(IndexConstants.ASYNC_PROPERTY_NAME, asList("async", "nrt"), Type.STRINGS);
        AsyncLaneSwitcher.switchLane(builder, "tmp-async");
        assertEquals("async", IndexImporter.getAsyncLaneName("foo", builder.getNodeState()));

        builder = EMPTY_NODE.builder();
        builder.setProperty(IndexConstants.ASYNC_PROPERTY_NAME, asList("async", "nrt"), Type.STRINGS);
        AsyncLaneSwitcher.switchLane(builder, "tmp-async");
        assertEquals("async", IndexImporter.getAsyncLaneName("foo", builder.getNodeState()));
    }

    @Test
    public void laneUnlockedInCaseOfFailure() throws Exception{
        NodeBuilder builder = store.getRoot().builder();
        createIndexDefinition(builder.child(INDEX_DEFINITIONS_NAME),
                "fooIndex", true, false, ImmutableSet.of("foo"), null)
                .setProperty(ASYNC_PROPERTY_NAME, "async");
        builder.child("a").setProperty("foo", "abc");
        builder.child("b").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        new AsyncIndexUpdate("async", store, provider).run();

        String checkpoint = createIndexDirs("/oak:index/fooIndex");

        builder = store.getRoot().builder();
        builder.child("c").setProperty("foo", "abc");
        builder.child("d").setProperty("foo", "abc");
        store.merge(builder, EmptyHook.INSTANCE, CommitInfo.EMPTY);

        new AsyncIndexUpdate("async", store, provider).run();

        IndexImporterProvider importerProvider = new IndexImporterProvider() {
            @Override
            public void importIndex(NodeState root, NodeBuilder defn, File indexDir) {

            }

            @Override
            public String getType() {
                return "property";
            }
        };

        final String exceptionMessage = "TEST MESSAGE";
        ClusterNodeStoreLock lock = new ClusterNodeStoreLock(store);
        provider = new PropertyIndexEditorProvider() {
            @Override
            public Editor getIndexEditor(@NotNull String type, @NotNull NodeBuilder definition,
                                         @NotNull NodeState root, @NotNull IndexUpdateCallback callback) {
                throw new RuntimeException(exceptionMessage);
            }
        };

        IndexImporter importer = new IndexImporter(store, temporaryFolder.getRoot(), provider, lock);
        importer.addImporterProvider(importerProvider);

        try {
            importer.importIndex();
            fail();
        } catch (RuntimeException ignore) {

        }

        assertFalse(lock.isLocked("async"));

        AsyncIndexerLock lock2 = new AsyncIndexerLock() {
            @Override
            public LockToken lock(String asyncIndexerLane) throws CommitFailedException {
                return mock(LockToken.class);
            }

            @Override
            public void unlock(LockToken token) throws CommitFailedException {
                throw new IllegalStateException("Exception in unlock");
            }
        };
        IndexImporter importer2 = new IndexImporter(store, temporaryFolder.getRoot(), provider, lock2);
        importer2.addImporterProvider(importerProvider);

        try {
            importer2.importIndex();
            fail();
        } catch (RuntimeException ignore) {
            assertEquals(exceptionMessage, ignore.getMessage());
        }
    }

    private static FilterImpl createFilter(NodeState root, String nodeTypeName) {
        NodeTypeInfoProvider nodeTypes = new NodeStateNodeTypeInfoProvider(root);
        NodeTypeInfo type = nodeTypes.getNodeTypeInfo(nodeTypeName);
        SelectorImpl selector = new SelectorImpl(type, nodeTypeName);
        return new FilterImpl(selector, "SELECT * FROM [" + nodeTypeName + "]",
                new QueryEngineSettings());
    }

    private static Set<String> find(PropertyIndexLookup lookup, String name,
                                    String value, Filter filter) {
        return Sets.newHashSet(lookup.query(filter, name, value == null ? null
                : PropertyValues.newString(value)));
    }

    private String createIndexDirs(String... indexPaths) throws IOException, CommitFailedException {
        String checkpoint = store.checkpoint(1000000);
        IndexerInfo info = new IndexerInfo(temporaryFolder.getRoot(), checkpoint);
        info.save();

        for (String indexPath : indexPaths){
            createIndexFolder(temporaryFolder.getRoot(), indexPath);
        }
        dumpIndexDefinitions(indexPaths);
        return checkpoint;
    }

    private void createIndexFolder(File indexFolder, String indexPath) throws IOException {
        String dirName = PathUtils.getName(indexPath);
        File indexDir = new File(indexFolder, dirName);
        File indexMeta = new File(indexDir, IndexerInfo.INDEX_METADATA_FILE_NAME);
        Properties p = new Properties();
        p.setProperty(IndexerInfo.PROP_INDEX_PATH, indexPath);
        indexDir.mkdir();
        PropUtils.writeTo(p, indexMeta, "index info");
    }

    private void dumpIndexDefinitions(String... indexPaths) throws IOException, CommitFailedException {
        IndexDefinitionPrinter printer = new IndexDefinitionPrinter(store, () -> asList(indexPaths));
        printer.setFilter("{\"properties\":[\"*\", \"-:childOrder\"],\"nodes\":[\"*\", \"-:index-definition\"]}");
        File file = new File(temporaryFolder.getRoot(), INDEX_DEFINITIONS_JSON);
        StringWriter sw = new StringWriter();
        PrintWriter pw = new PrintWriter(sw);
        printer.print(pw, Format.JSON, false);
        Files.write(sw.toString(), file, UTF_8);
    }
}
