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

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.jackrabbit.oak.api.Tree;
import org.apache.jackrabbit.oak.api.Type;
import org.apache.jackrabbit.oak.plugins.index.elastic.util.ElasticIndexUtils;
import org.apache.jackrabbit.oak.plugins.index.search.FieldNames;
import org.apache.jackrabbit.oak.plugins.index.search.util.IndexDefinitionBuilder;
import org.apache.jackrabbit.oak.stats.StatisticsProvider;
import org.junit.Ignore;
import org.junit.Test;

import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.StreamSupport;

import static org.apache.jackrabbit.oak.plugins.index.elastic.ElasticTestUtils.randomString;
import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasItem;
import static org.junit.Assert.assertTrue;
import static org.mockito.AdditionalMatchers.geq;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

public class ElasticContentTest extends ElasticAbstractQueryTest {

    private final ElasticMetricHandler spyMetricHandler =
            spy(new ElasticMetricHandler(StatisticsProvider.NOOP));

    @Override
    protected ElasticMetricHandler getMetricHandler() {
        return spyMetricHandler;
    }

    @Test
    public void indexWithAnalyzedProperty() throws Exception {
        reset(spyMetricHandler);
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").analyzed();
        String indexName = UUID.randomUUID().toString();
        Tree index = setIndex(indexName, builder);
        String indexAlias = getElasticIndexDefinition(index).getIndexAlias();
        root.commit();

        assertTrue(exists(index));
        assertThat(0L, equalTo(countDocuments(index)));
        // there are no updates, so metrics won't be refreshed
        verify(spyMetricHandler, never()).markSize(anyString(), anyLong(), anyLong());
        verify(spyMetricHandler, never()).markDocuments(anyString(), anyLong());

        reset(spyMetricHandler);
        Tree content = root.getTree("/").addChild("content");
        content.addChild("indexed").setProperty("a", "foo");
        content.addChild("not-indexed").setProperty("b", "foo");
        root.commit();

        verify(spyMetricHandler).markSize(eq(indexAlias), geq(0L), geq(0L));
        verify(spyMetricHandler).markDocuments(eq(indexAlias), geq(0L));
        assertEventually(() -> assertThat(countDocuments(index), equalTo(1L)));

        content.getChild("indexed").remove();
        root.commit();

        assertEventually(() -> assertThat(countDocuments(index), equalTo(0L)));

        // TODO: should the index be deleted when the definition gets removed?
        //index.remove();
        //root.commit();

        //assertFalse(exists(index));
    }

    @Test
    @Ignore("this test fails because of a bug with nodeScopeIndex (every node gets indexed in an empty doc)")
    public void indexWithAnalyzedNodeScopeIndexProperty() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").analyzed().nodeScopeIndex();
        Tree index = setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        assertThat(0L, equalTo(countDocuments(index)));

        Tree content = root.getTree("/").addChild("content");
        content.addChild("indexed").setProperty("a", "foo");
        content.addChild("not-indexed").setProperty("b", "foo");
        root.commit();

        assertEventually(() -> assertThat(countDocuments(index), equalTo(1L)));
    }

    @Test
    public void indexContentWithLongPath() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").analyzed();
        Tree index = setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        assertTrue(exists(index));
        assertThat(0L, equalTo(countDocuments(index)));

        int leftLimit = 48; // ' ' (space)
        int rightLimit = 122; // char '~'
        int targetStringLength = 1024;
        final Random random = new Random(42);

        String generatedPath = random.ints(leftLimit, rightLimit + 1)
                .limit(targetStringLength)
                .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
                .toString();

        Tree content = root.getTree("/").addChild("content");
        content.addChild(generatedPath).setProperty("a", "foo");
        root.commit();

        assertEventually(() -> assertThat(countDocuments(index), equalTo(1L)));
    }

    @Test
    public void defineIndexTwice() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        String testId = UUID.randomUUID().toString();
        Tree index = setIndex(testId, builder);
        root.commit();

        assertTrue(exists(index));

        builder = createIndex("a").noAsync();
        setIndex(testId, builder);
        root.commit();
    }

    @Test
    public void analyzedFieldWithLongValue() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").analyzed();
        Tree index = setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        assertTrue(exists(index));
        assertThat(0L, equalTo(countDocuments(index)));

        Tree content = root.getTree("/").addChild("content");
        content.addChild("indexed1").setProperty("a", randomString(33409)); // max length is 32766
        root.commit();

        assertEventually(() -> assertThat(countDocuments(index), equalTo(1L)));
    }

    @Test
    public void shardPreference() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").propertyIndex();
        setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        String query = "select [jcr:path] from [nt:base] where [a] = 'text'";
        String explain = explain(query);
        assertThat(explain, containsString("preference=oak-"));

        // preference session should be the same across the same query
        String explain2 = explain(query);
        assertThat(explain2, equalTo(explain));
    }

    @Test
    public void indexWithDefaultFetchSizes() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").propertyIndex();
        setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        Tree content = root.getTree("/").addChild("content");
        IntStream.range(0, 20).forEach(n -> {
                    Tree child = content.addChild("child_" + n);
                    child.setProperty("a", "text");
                }
        );
        root.commit(Map.of("sync-mode", "rt"));

        List<String> results = IntStream.range(0, 20).mapToObj(i -> "/content/child_" + i).collect(Collectors.toList());

        reset(spyMetricHandler);
        assertQuery("select [jcr:path] from [nt:base] where [a] = 'text'", results);
        verify(spyMetricHandler, times(2)).markQuery(anyString(), anyBoolean());
    }

    @Test
    public void indexWithCustomFetchSizes() throws Exception {
        BiConsumer<String, Iterable<Long>> buildIndex = (p, fetchSizes) -> {
            IndexDefinitionBuilder builder = createIndex(p).noAsync();
            builder.includedPaths("/content");
            builder.getBuilderTree().setProperty("queryFetchSizes", fetchSizes, Type.LONGS);
            builder.indexRule("nt:base").property(p).propertyIndex();
            setIndex(p + "_" + UUID.randomUUID(), builder);
        };

        buildIndex.accept("a", List.of(1L));
        buildIndex.accept("b", List.of(1L, 2L));
        buildIndex.accept("c", List.of(3L, 100L));
        root.commit();

        Tree content = root.getTree("/").addChild("content");
        IntStream.range(0, 3).forEach(n -> {
                    Tree child = content.addChild("child_" + n);
                    child.setProperty("a", "text");
                    child.setProperty("b", "text");
                    child.setProperty("c", "text");
                }
        );
        root.commit(Map.of("sync-mode", "rt"));

        List<String> results = IntStream.range(0, 3).mapToObj(i -> "/content/child_" + i).collect(Collectors.toList());

        reset(spyMetricHandler);
        assertQuery("select [jcr:path] from [nt:base] where [a] = 'text'", results);
        verify(spyMetricHandler, times(3)).markQuery(anyString(), anyBoolean());

        reset(spyMetricHandler);
        assertQuery("select [jcr:path] from [nt:base] where [b] = 'text'", results);
        verify(spyMetricHandler, times(2)).markQuery(anyString(), anyBoolean());

        reset(spyMetricHandler);
        assertQuery("select [jcr:path] from [nt:base] where [c] = 'text'", results);
        verify(spyMetricHandler, times(1)).markQuery(anyString(), anyBoolean());
    }

    @Test
    public void indexWithLowTrackTotalHits() throws Exception {
        BiConsumer<String, Iterable<Long>> buildIndex = (p, fetchSizes) -> {
            IndexDefinitionBuilder builder = createIndex(p).noAsync();
            builder.includedPaths("/content");
            builder.getBuilderTree().setProperty("queryFetchSizes", fetchSizes, Type.LONGS);
            builder.getBuilderTree().setProperty("trackTotalHits", 10L, Type.LONG);
            builder.indexRule("nt:base").property(p).propertyIndex();
            setIndex(p + "_" + UUID.randomUUID(), builder);
        };

        buildIndex.accept("a", List.of(10L));
        root.commit();

        Tree content = root.getTree("/").addChild("content");

        List<String> results = IntStream.range(0, 100)
                .mapToObj(n -> {
                    Tree child = content.addChild("child_" + n);
                    child.setProperty("a", "text");
                    return "/content/child_" + n;
                })
                .collect(Collectors.toList());

        root.commit();

        assertEventually(() -> assertQuery("select [jcr:path] from [nt:base] where [a] = 'text'", results));
    }

    @Test
    public void deduplicateFields() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").propertyIndex();
        Tree index = setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        Tree content = root.getTree("/").addChild("content");
        content.addChild("indexed1").setProperty("a", List.of("foo", "foo"), Type.STRINGS);
        content.addChild("indexed2").setProperty("a", List.of("foo", "bar", "foo"), Type.STRINGS);
        root.commit();

        assertEventually(() -> {
            ObjectNode indexed1 = getDocument(index, "/content/indexed1");
            assertThat(indexed1.get(ElasticIndexUtils.fieldName("a")).asText(), equalTo("foo"));

            ObjectNode indexed2 = getDocument(index, "/content/indexed2");
            assertThat(indexed2.get(ElasticIndexUtils.fieldName("a")).size(), equalTo(2));
            assertThat(indexed2.get(ElasticIndexUtils.fieldName("a")).get(0).asText(), equalTo("foo"));
            assertThat(indexed2.get(ElasticIndexUtils.fieldName("a")).get(1).asText(), equalTo("bar"));
        });
    }

    @Test
    public void indexAliasContainsMappingVersionWhenGreatestThanOne() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").propertyIndex();
        Tree index = setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        ElasticIndexDefinition definition = getElasticIndexDefinition(index);
        String indexAlias = definition.getIndexAlias();
        if (ElasticIndexDefinition.MAPPING_VERSION.getMajor() > 1) {
            assertThat(indexAlias, endsWith("_v" + ElasticIndexDefinition.MAPPING_VERSION));
        } else {
            assertThat(indexAlias, not(endsWith("_v" + ElasticIndexDefinition.MAPPING_VERSION)));
        }
    }

    @Test
    public void fulltextFieldValuesCleanup() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").nodeScopeIndex();
        Tree index = setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        Tree content = root.getTree("/").addChild("content");
        content.addChild("indexed1").setProperty("a", "foo bar baz");
        content.addChild("indexed2").setProperty("a", "\nfoo foo baz\n");
        content.addChild("indexed3").setProperty("a", "\n\n\n  foo foo baz  \n\n\n");
        root.commit();

        assertEventually(() -> {
            ObjectNode indexed1 = getDocument(index, "/content/indexed1");
            List<String> values1 = StreamSupport.stream(
                    indexed1.get(ElasticIndexUtils.fieldName(FieldNames.FULLTEXT)).spliterator(), false)
                    .map(JsonNode::asText)
                    .collect(Collectors.toList());
            assertThat(values1, hasItem("foo bar baz"));

            ObjectNode indexed2 = getDocument(index, "/content/indexed2");
            List<String> values2 = StreamSupport.stream(
                    indexed2.get(ElasticIndexUtils.fieldName(FieldNames.FULLTEXT)).spliterator(), false)
                    .map(JsonNode::asText)
                    .collect(Collectors.toList());
            assertThat(values2, hasItem("foo foo baz"));

            ObjectNode indexed3 = getDocument(index, "/content/indexed3");
            List<String> values3 = StreamSupport.stream(
                    indexed3.get(ElasticIndexUtils.fieldName(FieldNames.FULLTEXT)).spliterator(), false)
                    .map(JsonNode::asText)
                    .collect(Collectors.toList());
            assertThat(values3, hasItem("foo foo baz"));
        });
    }

    @Test
    public void subtreeDeletion() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").propertyIndex();
        Tree index = setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        Tree content = root.getTree("/").addChild("content");
        Tree parent = content.addChild("parent");
        parent.setProperty("a", "foo");
        parent.addChild("child1").setProperty("a", "foo");
        parent.addChild("child2").setProperty("a", "foo");
        parent.addChild("child3").setProperty("a", "foo");
        // this node is added to check that only the subtree of the removed node gets deleted from the index
        Tree otherParent = content.addChild("otherParent");
        otherParent.setProperty("a", "foo");
        otherParent.addChild("child4").setProperty("a", "foo");
        root.commit();

        assertEventually(() -> assertThat(countDocuments(index), equalTo(6L)));

        content.getChild("parent").remove();
        root.commit();

        assertEventually(() -> assertThat(countDocuments(index), equalTo(2L)));
    }

    @Test
    public void propertyRemoval() throws Exception {
        IndexDefinitionBuilder builder = createIndex("a", "b", "c").noAsync();
        builder.includedPaths("/content");
        builder.indexRule("nt:base").property("a").propertyIndex();
        builder.indexRule("nt:base").property("b").propertyIndex();
        builder.indexRule("nt:base").property("c").propertyIndex();
        // partial updates only happen when the index is externally modifiable, aka inference config is enabled
        builder.getBuilderTree().addChild(ElasticIndexDefinition.INFERENCE_CONFIG);
        Tree index = setIndex(UUID.randomUUID().toString(), builder);
        root.commit();

        Tree content = root.getTree("/").addChild("content");
        Tree node = content.addChild("node");
        node.setProperty("a", "foo");
        node.setProperty("b", "foo");
        node.setProperty("c", List.of("foo", "bar"), Type.STRINGS);
        root.commit();
        assertEventually(() -> {
            ObjectNode doc = getDocument(index, "/content/node");
            assertThat(doc.get(ElasticIndexUtils.fieldName("a")).asText(), equalTo("foo"));
            assertThat(doc.get(ElasticIndexUtils.fieldName("b")).asText(), equalTo("foo"));
            assertThat(doc.get(ElasticIndexUtils.fieldName("c")).size(), equalTo(2));
        });

        node.removeProperty(ElasticIndexUtils.fieldName("a"));
        node.setProperty("b", "bar");
        node.setProperty("c", List.of(), Type.STRINGS);
        root.commit();
        assertEventually(() -> {
            ObjectNode doc = getDocument(index, "/content/node");
            assertThat(doc.get(ElasticIndexUtils.fieldName("a")), equalTo(null));
            assertThat(doc.get(ElasticIndexUtils.fieldName("b")).asText(), equalTo("bar"));
            assertThat(doc.get(ElasticIndexUtils.fieldName("c")), equalTo(null));
        });
    }

}
