/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch 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.elasticsearch.search.fetch.subphase.highlight;

import org.apache.lucene.search.Query;
import org.elasticsearch.Version;
import org.elasticsearch.cluster.metadata.IndexMetaData;
import org.elasticsearch.common.ParsingException;
import org.elasticsearch.common.io.stream.BytesStreamOutput;
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
import org.elasticsearch.common.xcontent.ToXContent;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.common.xcontent.json.JsonXContent;
import org.elasticsearch.index.Index;
import org.elasticsearch.index.IndexSettings;
import org.elasticsearch.index.mapper.ContentPath;
import org.elasticsearch.index.mapper.MappedFieldType;
import org.elasticsearch.index.mapper.Mapper;
import org.elasticsearch.index.mapper.TextFieldMapper;
import org.elasticsearch.index.query.IdsQueryBuilder;
import org.elasticsearch.index.query.MatchAllQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryParseContext;
import org.elasticsearch.index.query.QueryShardContext;
import org.elasticsearch.index.query.TermQueryBuilder;
import org.elasticsearch.search.SearchModule;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder.BoundaryScannerType;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder.Field;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder.Order;
import org.elasticsearch.search.fetch.subphase.highlight.SearchContextHighlight.FieldOptions;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.test.IndexSettingsModule;
import org.junit.AfterClass;
import org.junit.BeforeClass;

import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.BiConsumer;
import java.util.function.Function;

import static java.util.Collections.emptyList;
import static org.elasticsearch.test.EqualsHashCodeTestUtils.checkEqualsAndHashCode;
import static org.hamcrest.Matchers.equalTo;

public class HighlightBuilderTests extends ESTestCase {

    private static final int NUMBER_OF_TESTBUILDERS = 20;
    private static NamedWriteableRegistry namedWriteableRegistry;
    private static NamedXContentRegistry xContentRegistry;

    /**
     * setup for the whole base test class
     */
    @BeforeClass
    public static void init() {
        SearchModule searchModule = new SearchModule(Settings.EMPTY, false, emptyList());
        namedWriteableRegistry = new NamedWriteableRegistry(searchModule.getNamedWriteables());
        xContentRegistry = new NamedXContentRegistry(searchModule.getNamedXContents());
    }

    @AfterClass
    public static void afterClass() throws Exception {
        namedWriteableRegistry = null;
        xContentRegistry = null;
    }

    /**
     * Test serialization and deserialization of the highlighter builder
     */
    public void testSerialization() throws IOException {
        for (int runs = 0; runs < NUMBER_OF_TESTBUILDERS; runs++) {
            HighlightBuilder original = randomHighlighterBuilder();
            HighlightBuilder deserialized = serializedCopy(original);
            assertEquals(deserialized, original);
            assertEquals(deserialized.hashCode(), original.hashCode());
            assertNotSame(deserialized, original);
        }
    }

    /**
     * Test equality and hashCode properties
     */
    public void testEqualsAndHashcode() throws IOException {
        for (int runs = 0; runs < NUMBER_OF_TESTBUILDERS; runs++) {
            checkEqualsAndHashCode(randomHighlighterBuilder(), HighlightBuilderTests::serializedCopy, HighlightBuilderTests::mutate);
        }
    }

    /**
     *  creates random highlighter, renders it to xContent and back to new instance that should be equal to original
     */
    public void testFromXContent() throws IOException {
        for (int runs = 0; runs < NUMBER_OF_TESTBUILDERS; runs++) {
            HighlightBuilder highlightBuilder = randomHighlighterBuilder();
            XContentBuilder builder = XContentFactory.contentBuilder(randomFrom(XContentType.values()));
            if (randomBoolean()) {
                builder.prettyPrint();
            }

            XContentBuilder shuffled;
            if (randomBoolean()) {
                //this way `fields` is printed out as a json array
                highlightBuilder.useExplicitFieldOrder(true);
                highlightBuilder.toXContent(builder, ToXContent.EMPTY_PARAMS);
                shuffled = shuffleXContent(builder);
            } else {
                highlightBuilder.toXContent(builder, ToXContent.EMPTY_PARAMS);
                shuffled = shuffleXContent(builder, "fields");
            }

            XContentParser parser = createParser(shuffled);
            QueryParseContext context = new QueryParseContext(parser);
            parser.nextToken();
            HighlightBuilder secondHighlightBuilder;
            try {
                secondHighlightBuilder = HighlightBuilder.fromXContent(context);
            } catch (RuntimeException e) {
                throw new RuntimeException("Error parsing " + highlightBuilder, e);
            }
            assertNotSame(highlightBuilder, secondHighlightBuilder);
            assertEquals(highlightBuilder, secondHighlightBuilder);
            assertEquals(highlightBuilder.hashCode(), secondHighlightBuilder.hashCode());
        }
    }

    /**
     * test that unknown array fields cause exception
     */
    public void testUnknownArrayNameExpection() throws IOException {
        {
            IllegalArgumentException e = expectParseThrows(IllegalArgumentException.class, "{\n" +
                    "    \"bad_fieldname\" : [ \"field1\" 1 \"field2\" ]\n" +
                    "}\n");
            assertEquals("[highlight] unknown field [bad_fieldname], parser not found", e.getMessage());
        }

        {
            ParsingException e = expectParseThrows(ParsingException.class, "{\n" +
                    "  \"fields\" : {\n" +
                    "     \"body\" : {\n" +
                    "        \"bad_fieldname\" : [ \"field1\" , \"field2\" ]\n" +
                    "     }\n" +
                    "   }\n" +
                    "}\n");
            assertEquals("[highlight] failed to parse field [fields]", e.getMessage());
            assertEquals("[fields] failed to parse field [body]", e.getCause().getMessage());
            assertEquals("[highlight_field] unknown field [bad_fieldname], parser not found", e.getCause().getCause().getMessage());
        }
    }

    private <T extends Throwable> T expectParseThrows(Class<T> exceptionClass, String highlightElement) throws IOException {
        XContentParser parser = createParser(JsonXContent.jsonXContent, highlightElement);
        QueryParseContext context = new QueryParseContext(parser);
        return expectThrows(exceptionClass, () -> HighlightBuilder.fromXContent(context));
    }

    /**
     * test that unknown field name cause exception
     */
    public void testUnknownFieldnameExpection() throws IOException {
        {
            IllegalArgumentException e = expectParseThrows(IllegalArgumentException.class, "{\n" +
                    "    \"bad_fieldname\" : \"value\"\n" +
                    "}\n");
            assertEquals("[highlight] unknown field [bad_fieldname], parser not found", e.getMessage());
        }

        {
            ParsingException e = expectParseThrows(ParsingException.class, "{\n" +
                    "  \"fields\" : {\n" +
                    "     \"body\" : {\n" +
                    "        \"bad_fieldname\" : \"value\"\n" +
                    "     }\n" +
                    "   }\n" +
                    "}\n");
            assertEquals("[highlight] failed to parse field [fields]", e.getMessage());
            assertEquals("[fields] failed to parse field [body]", e.getCause().getMessage());
            assertEquals("[highlight_field] unknown field [bad_fieldname], parser not found", e.getCause().getCause().getMessage());
        }
    }

    /**
     * test that unknown field name cause exception
     */
    public void testUnknownObjectFieldnameExpection() throws IOException {
        {
            IllegalArgumentException e = expectParseThrows(IllegalArgumentException.class, "{\n" +
                    "    \"bad_fieldname\" :  { \"field\" : \"value\" }\n \n" +
                    "}\n");
            assertEquals("[highlight] unknown field [bad_fieldname], parser not found", e.getMessage());
        }

        {
            ParsingException e = expectParseThrows(ParsingException.class, "{\n" +
                    "  \"fields\" : {\n" +
                    "     \"body\" : {\n" +
                    "        \"bad_fieldname\" : { \"field\" : \"value\" }\n" +
                    "     }\n" +
                    "   }\n" +
                    "}\n");
            assertEquals("[highlight] failed to parse field [fields]", e.getMessage());
            assertEquals("[fields] failed to parse field [body]", e.getCause().getMessage());
            assertEquals("[highlight_field] unknown field [bad_fieldname], parser not found", e.getCause().getCause().getMessage());
        }
    }

    public void testStringInFieldsArray() throws IOException {
        ParsingException e = expectParseThrows(ParsingException.class, "{\"fields\" : [ \"junk\" ]}");
        assertEquals("[highlight] failed to parse field [fields]", e.getMessage());
        assertEquals(
                "[fields] can be a single object with any number of fields or an array where each entry is an object with a single field",
                e.getCause().getMessage());
    }

    public void testNoFieldsInObjectInFieldsArray() throws IOException {
        ParsingException e = expectParseThrows(ParsingException.class, "{\n" +
                "  \"fields\" : [ {\n" +
                "   }] \n" +
                "}\n");
        assertEquals("[highlight] failed to parse field [fields]", e.getMessage());
        assertEquals(
                "[fields] can be a single object with any number of fields or an array where each entry is an object with a single field",
                e.getCause().getMessage());
    }

    public void testTwoFieldsInObjectInFieldsArray() throws IOException {
        ParsingException e = expectParseThrows(ParsingException.class, "{\n" +
                "  \"fields\" : [ {\n" +
                "     \"body\" : {},\n" +
                "     \"nope\" : {}\n" +
                "   }] \n" +
                "}\n");
        assertEquals("[highlight] failed to parse field [fields]", e.getMessage());
        assertEquals(
                "[fields] can be a single object with any number of fields or an array where each entry is an object with a single field",
                e.getCause().getMessage());    }

     /**
     * test that build() outputs a {@link SearchContextHighlight} that is has similar parameters
     * than what we have in the random {@link HighlightBuilder}
     */
    public void testBuildSearchContextHighlight() throws IOException {
        Settings indexSettings = Settings.builder()
                .put(IndexMetaData.SETTING_VERSION_CREATED, Version.CURRENT).build();
        Index index = new Index(randomAlphaOfLengthBetween(1, 10), "_na_");
        IndexSettings idxSettings = IndexSettingsModule.newIndexSettings(index, indexSettings);
        // shard context will only need indicesQueriesRegistry for building Query objects nested in highlighter
        QueryShardContext mockShardContext = new QueryShardContext(0, idxSettings, null, null, null, null, null, xContentRegistry(),
                null, null, System::currentTimeMillis) {
            @Override
            public MappedFieldType fieldMapper(String name) {
                TextFieldMapper.Builder builder = new TextFieldMapper.Builder(name);
                return builder.build(new Mapper.BuilderContext(idxSettings.getSettings(), new ContentPath(1))).fieldType();
            }
        };
        mockShardContext.setMapUnmappedFieldAsString(true);

        for (int runs = 0; runs < NUMBER_OF_TESTBUILDERS; runs++) {
            HighlightBuilder highlightBuilder = randomHighlighterBuilder();
            SearchContextHighlight highlight = highlightBuilder.build(mockShardContext);
            for (SearchContextHighlight.Field field : highlight.fields()) {
                String encoder = highlightBuilder.encoder() != null ? highlightBuilder.encoder() : HighlightBuilder.DEFAULT_ENCODER;
                assertEquals(encoder, field.fieldOptions().encoder());
                final Field fieldBuilder = getFieldBuilderByName(highlightBuilder, field.field());
                assertNotNull("expected a highlight builder for field " + field.field(), fieldBuilder);
                FieldOptions fieldOptions = field.fieldOptions();

                BiConsumer<Function<AbstractHighlighterBuilder<?>, Object>, Function<FieldOptions, Object>> checkSame =
                        mergeBeforeChek(highlightBuilder, fieldBuilder, fieldOptions);

                checkSame.accept(AbstractHighlighterBuilder::boundaryChars, FieldOptions::boundaryChars);
                checkSame.accept(AbstractHighlighterBuilder::boundaryScannerType, FieldOptions::boundaryScannerType);
                checkSame.accept(AbstractHighlighterBuilder::boundaryMaxScan, FieldOptions::boundaryMaxScan);
                checkSame.accept(AbstractHighlighterBuilder::fragmentSize, FieldOptions::fragmentCharSize);
                checkSame.accept(AbstractHighlighterBuilder::fragmenter, FieldOptions::fragmenter);
                checkSame.accept(AbstractHighlighterBuilder::requireFieldMatch, FieldOptions::requireFieldMatch);
                checkSame.accept(AbstractHighlighterBuilder::noMatchSize, FieldOptions::noMatchSize);
                checkSame.accept(AbstractHighlighterBuilder::numOfFragments, FieldOptions::numberOfFragments);
                checkSame.accept(AbstractHighlighterBuilder::phraseLimit, FieldOptions::phraseLimit);
                checkSame.accept(AbstractHighlighterBuilder::highlighterType, FieldOptions::highlighterType);
                checkSame.accept(AbstractHighlighterBuilder::highlightFilter, FieldOptions::highlightFilter);
                checkSame.accept(AbstractHighlighterBuilder::preTags, FieldOptions::preTags);
                checkSame.accept(AbstractHighlighterBuilder::postTags, FieldOptions::postTags);
                checkSame.accept(AbstractHighlighterBuilder::options, FieldOptions::options);
                checkSame.accept(AbstractHighlighterBuilder::order, op -> op.scoreOrdered() ? Order.SCORE : Order.NONE);
                assertEquals(fieldBuilder.fragmentOffset, fieldOptions.fragmentOffset());
                if (fieldBuilder.matchedFields != null) {
                    String[] copy = Arrays.copyOf(fieldBuilder.matchedFields, fieldBuilder.matchedFields.length);
                    Arrays.sort(copy);
                    assertArrayEquals(copy,
                            new TreeSet<>(fieldOptions.matchedFields()).toArray(new String[fieldOptions.matchedFields().size()]));
                } else {
                    assertNull(fieldOptions.matchedFields());
                }
                Query expectedValue = null;
                if (fieldBuilder.highlightQuery != null) {
                    expectedValue = QueryBuilder.rewriteQuery(fieldBuilder.highlightQuery, mockShardContext).toQuery(mockShardContext);
                } else if (highlightBuilder.highlightQuery != null) {
                    expectedValue = QueryBuilder.rewriteQuery(highlightBuilder.highlightQuery, mockShardContext).toQuery(mockShardContext);
                }
                assertEquals(expectedValue, fieldOptions.highlightQuery());
            }
        }
    }

    /**
     * Create a generic helper function that performs all the work of merging the global highlight builder parameter,
     * the (potential) overwrite on the field level and the default value from {@link HighlightBuilder#defaultOptions}
     * before making the assertion that the value in the highlight builder and the actual value in the {@link FieldOptions}
     * passed in is the same.
     *
     * @param highlightBuilder provides the (optional) global builder parameter
     * @param fieldBuilder provides the (optional) field level parameter, if present this overwrites the global value
     * @param options the target field options that are checked
     */
    private static BiConsumer<Function<AbstractHighlighterBuilder<?>, Object>, Function<FieldOptions, Object>> mergeBeforeChek(
            HighlightBuilder highlightBuilder, Field fieldBuilder, FieldOptions options) {
        return (highlightBuilderParameterAccessor, fieldOptionsParameterAccessor) -> {
            Object expectedValue = null;
            Object globalLevelValue = highlightBuilderParameterAccessor.apply(highlightBuilder);
            Object fieldLevelValue = highlightBuilderParameterAccessor.apply(fieldBuilder);
            if (fieldLevelValue != null) {
                expectedValue = fieldLevelValue;
            } else if (globalLevelValue != null) {
                expectedValue = globalLevelValue;
            } else {
                expectedValue = fieldOptionsParameterAccessor.apply(HighlightBuilder.defaultOptions);
            }
            Object actualValue = fieldOptionsParameterAccessor.apply(options);
            if (actualValue instanceof String[]) {
                assertArrayEquals((String[]) expectedValue, (String[]) actualValue);
            } else if (actualValue instanceof Character[]) {
                if (expectedValue instanceof char[]) {
                    assertArrayEquals(HighlightBuilder.convertCharArray((char[]) expectedValue), (Character[]) actualValue);
                } else {
                    assertArrayEquals((Character[]) expectedValue, (Character[]) actualValue);
                }
            } else {
                assertEquals(expectedValue, actualValue);
            }
        };
    }

    private static Field getFieldBuilderByName(HighlightBuilder highlightBuilder, String fieldName) {
        for (Field hbfield : highlightBuilder.fields()) {
            if (hbfield.name().equals(fieldName)) {
                return hbfield;
            }
        }
        return null;
    }

    /**
     * `tags_schema` is not produced by toXContent in the builder but should be parseable, so this
     * adds a simple json test for this.
     */
    public void testParsingTagsSchema() throws IOException {

        String highlightElement = "{\n" +
                "    \"tags_schema\" : \"styled\"\n" +
                "}\n";
        XContentParser parser = createParser(JsonXContent.jsonXContent, highlightElement);

        QueryParseContext context = new QueryParseContext(parser);
        HighlightBuilder highlightBuilder = HighlightBuilder.fromXContent(context);
        assertArrayEquals("setting tags_schema 'styled' should alter pre_tags", HighlightBuilder.DEFAULT_STYLED_PRE_TAG,
                highlightBuilder.preTags());
        assertArrayEquals("setting tags_schema 'styled' should alter post_tags", HighlightBuilder.DEFAULT_STYLED_POST_TAGS,
                highlightBuilder.postTags());

        highlightElement = "{\n" +
                "    \"tags_schema\" : \"default\"\n" +
                "}\n";
        parser = createParser(JsonXContent.jsonXContent, highlightElement);

        context = new QueryParseContext(parser);
        highlightBuilder = HighlightBuilder.fromXContent(context);
        assertArrayEquals("setting tags_schema 'default' should alter pre_tags", HighlightBuilder.DEFAULT_PRE_TAGS,
                highlightBuilder.preTags());
        assertArrayEquals("setting tags_schema 'default' should alter post_tags", HighlightBuilder.DEFAULT_POST_TAGS,
                highlightBuilder.postTags());

        ParsingException e = expectParseThrows(ParsingException.class, "{\n" +
                "    \"tags_schema\" : \"somthing_else\"\n" +
                "}\n");
        assertEquals("[highlight] failed to parse field [tags_schema]", e.getMessage());
        assertEquals("Unknown tag schema [somthing_else]", e.getCause().getMessage());
    }

    /**
     * test parsing empty highlight or empty fields blocks
     */
    public void testParsingEmptyStructure() throws IOException {
        String highlightElement = "{ }";
        XContentParser parser = createParser(JsonXContent.jsonXContent, highlightElement);

        QueryParseContext context = new QueryParseContext(parser);
        HighlightBuilder highlightBuilder = HighlightBuilder.fromXContent(context);
        assertEquals("expected plain HighlightBuilder", new HighlightBuilder(), highlightBuilder);

        highlightElement = "{ \"fields\" : { } }";
        parser = createParser(JsonXContent.jsonXContent, highlightElement);

        context = new QueryParseContext(parser);
        highlightBuilder = HighlightBuilder.fromXContent(context);
        assertEquals("defining no field should return plain HighlightBuilder", new HighlightBuilder(), highlightBuilder);

        highlightElement = "{ \"fields\" : { \"foo\" : { } } }";
        parser = createParser(JsonXContent.jsonXContent, highlightElement);

        context = new QueryParseContext(parser);
        highlightBuilder = HighlightBuilder.fromXContent(context);
        assertEquals("expected HighlightBuilder with field", new HighlightBuilder().field(new Field("foo")), highlightBuilder);
    }

    public void testPreTagsWithoutPostTags() throws IOException {
        ParsingException e = expectParseThrows(ParsingException.class, "{\n" +
                "    \"pre_tags\" : [\"<a>\"]\n" +
                "}\n");
        assertEquals("pre_tags are set but post_tags are not set", e.getMessage());

        e = expectParseThrows(ParsingException.class, "{\n" +
                "  \"fields\" : {\n" +
                "     \"body\" : {\n" +
                "        \"pre_tags\" : [\"<a>\"]\n" +
                "     }\n" +
                "   }\n" +
                "}\n");
        assertEquals("[highlight] failed to parse field [fields]", e.getMessage());
        assertEquals("[fields] failed to parse field [body]", e.getCause().getMessage());
        assertEquals("pre_tags are set but post_tags are not set", e.getCause().getCause().getMessage());
    }

    /**
     * test ordinals of {@link Order}, since serialization depends on it
     */
    public void testValidOrderOrdinals() {
        assertThat(Order.NONE.ordinal(), equalTo(0));
        assertThat(Order.SCORE.ordinal(), equalTo(1));
    }

    public void testOrderSerialization() throws Exception {
        try (BytesStreamOutput out = new BytesStreamOutput()) {
            Order.NONE.writeTo(out);
            try (StreamInput in = out.bytes().streamInput()) {
                assertThat(in.readVInt(), equalTo(0));
            }
        }

        try (BytesStreamOutput out = new BytesStreamOutput()) {
            Order.SCORE.writeTo(out);
            try (StreamInput in = out.bytes().streamInput()) {
                assertThat(in.readVInt(), equalTo(1));
            }
        }
    }

    protected static XContentBuilder toXContent(HighlightBuilder highlight, XContentType contentType) throws IOException {
        XContentBuilder builder = XContentFactory.contentBuilder(contentType);
        if (randomBoolean()) {
            builder.prettyPrint();
        }
        highlight.toXContent(builder, ToXContent.EMPTY_PARAMS);
        return builder;
    }

    /**
     * create random highlight builder that is put under test
     */
    public static HighlightBuilder randomHighlighterBuilder() {
        HighlightBuilder testHighlighter = new HighlightBuilder();
        setRandomCommonOptions(testHighlighter);
        testHighlighter.useExplicitFieldOrder(randomBoolean());
        if (randomBoolean()) {
            testHighlighter.encoder(randomFrom(Arrays.asList(new String[]{"default", "html"})));
        }
        int numberOfFields = randomIntBetween(1,5);
        for (int i = 0; i < numberOfFields; i++) {
            Field field = new Field(i + "_" + randomAlphaOfLengthBetween(1, 10));
            setRandomCommonOptions(field);
            if (randomBoolean()) {
                field.fragmentOffset(randomIntBetween(1, 100));
            }
            if (randomBoolean()) {
                field.matchedFields(randomStringArray(0, 4));
            }
            testHighlighter.field(field);
        }
        return testHighlighter;
    }

    @SuppressWarnings({ "rawtypes"})
    private static void setRandomCommonOptions(AbstractHighlighterBuilder highlightBuilder) {
        if (randomBoolean()) {
            // need to set this together, otherwise parsing will complain
            highlightBuilder.preTags(randomStringArray(0, 3));
            highlightBuilder.postTags(randomStringArray(0, 3));
        }
        if (randomBoolean()) {
            highlightBuilder.fragmentSize(randomIntBetween(0, 100));
        }
        if (randomBoolean()) {
            highlightBuilder.numOfFragments(randomIntBetween(0, 10));
        }
        if (randomBoolean()) {
            highlightBuilder.highlighterType(randomAlphaOfLengthBetween(1, 10));
        }
        if (randomBoolean()) {
            highlightBuilder.fragmenter(randomAlphaOfLengthBetween(1, 10));
        }
        if (randomBoolean()) {
            QueryBuilder highlightQuery;
            switch (randomInt(2)) {
            case 0:
                highlightQuery = new MatchAllQueryBuilder();
                break;
            case 1:
                highlightQuery = new IdsQueryBuilder();
                break;
            default:
            case 2:
                highlightQuery = new TermQueryBuilder(randomAlphaOfLengthBetween(1, 10), randomAlphaOfLengthBetween(1, 10));
                break;
            }
            highlightQuery.boost((float) randomDoubleBetween(0, 10, false));
            highlightBuilder.highlightQuery(highlightQuery);
        }
        if (randomBoolean()) {
            if (randomBoolean()) {
                highlightBuilder.order(randomFrom(Order.values()));
            } else {
                // also test the string setter
                highlightBuilder.order(randomFrom(Order.values()).toString());
            }
        }
        if (randomBoolean()) {
            highlightBuilder.highlightFilter(randomBoolean());
        }
        if (randomBoolean()) {
            highlightBuilder.forceSource(randomBoolean());
        }
        if (randomBoolean()) {
            if (randomBoolean()) {
                highlightBuilder.boundaryScannerType(randomFrom(BoundaryScannerType.values()));
            } else {
                // also test the string setter
                highlightBuilder.boundaryScannerType(randomFrom(BoundaryScannerType.values()).toString());
            }
        }
        if (randomBoolean()) {
            highlightBuilder.boundaryMaxScan(randomIntBetween(0, 10));
        }
        if (randomBoolean()) {
            highlightBuilder.boundaryChars(randomAlphaOfLengthBetween(1, 10).toCharArray());
        }
        if (randomBoolean()) {
            highlightBuilder.boundaryScannerLocale(randomLocale(random()).toLanguageTag());
        }
        if (randomBoolean()) {
            highlightBuilder.noMatchSize(randomIntBetween(0, 10));
        }
        if (randomBoolean()) {
            highlightBuilder.phraseLimit(randomIntBetween(0, 10));
        }
        if (randomBoolean()) {
            int items = randomIntBetween(0, 5);
            Map<String, Object> options = new HashMap<>(items);
            for (int i = 0; i < items; i++) {
                Object value = null;
                switch (randomInt(2)) {
                case 0:
                    value = randomAlphaOfLengthBetween(1, 10);
                    break;
                case 1:
                    value = new Integer(randomInt(1000));
                    break;
                case 2:
                    value = new Boolean(randomBoolean());
                    break;
                }
                options.put(randomAlphaOfLengthBetween(1, 10), value);
            }
        }
        if (randomBoolean()) {
            highlightBuilder.requireFieldMatch(randomBoolean());
        }
    }

    @SuppressWarnings({ "unchecked", "rawtypes" })
    private static void mutateCommonOptions(AbstractHighlighterBuilder highlightBuilder) {
        switch (randomIntBetween(1, 16)) {
        case 1:
            highlightBuilder.preTags(randomStringArray(4, 6));
            break;
        case 2:
            highlightBuilder.postTags(randomStringArray(4, 6));
            break;
        case 3:
            highlightBuilder.fragmentSize(randomIntBetween(101, 200));
            break;
        case 4:
            highlightBuilder.numOfFragments(randomIntBetween(11, 20));
            break;
        case 5:
            highlightBuilder.highlighterType(randomAlphaOfLengthBetween(11, 20));
            break;
        case 6:
            highlightBuilder.fragmenter(randomAlphaOfLengthBetween(11, 20));
            break;
        case 7:
            highlightBuilder.highlightQuery(new TermQueryBuilder(randomAlphaOfLengthBetween(11, 20), randomAlphaOfLengthBetween(11, 20)));
            break;
        case 8:
            if (highlightBuilder.order() == Order.NONE) {
                highlightBuilder.order(Order.SCORE);
            } else {
                highlightBuilder.order(Order.NONE);
            }
            break;
        case 9:
            highlightBuilder.highlightFilter(toggleOrSet(highlightBuilder.highlightFilter()));
            break;
        case 10:
            highlightBuilder.forceSource(toggleOrSet(highlightBuilder.forceSource()));
            break;
        case 11:
            highlightBuilder.boundaryMaxScan(randomIntBetween(11, 20));
            break;
        case 12:
            highlightBuilder.boundaryChars(randomAlphaOfLengthBetween(11, 20).toCharArray());
            break;
        case 13:
            highlightBuilder.noMatchSize(randomIntBetween(11, 20));
            break;
        case 14:
            highlightBuilder.phraseLimit(randomIntBetween(11, 20));
            break;
        case 15:
            int items = 6;
            Map<String, Object> options = new HashMap<>(items);
            for (int i = 0; i < items; i++) {
                options.put(randomAlphaOfLengthBetween(1, 10), randomAlphaOfLengthBetween(1, 10));
            }
            highlightBuilder.options(options);
            break;
        case 16:
            highlightBuilder.requireFieldMatch(toggleOrSet(highlightBuilder.requireFieldMatch()));
            break;
        }
    }

    private static Boolean toggleOrSet(Boolean flag) {
        if (flag == null) {
            return randomBoolean();
        } else {
            return !flag.booleanValue();
        }
    }

    /**
     * Create array of unique Strings. If not unique, e.g. duplicates field names
     * would be dropped in {@link FieldOptions.Builder#matchedFields(Set)}, resulting in test glitches
     */
    private static String[] randomStringArray(int minSize, int maxSize) {
        int size = randomIntBetween(minSize, maxSize);
        Set<String> randomStrings = new HashSet<>(size);
        for (int f = 0; f < size; f++) {
            randomStrings.add(randomAlphaOfLengthBetween(3, 10));
        }
        return randomStrings.toArray(new String[randomStrings.size()]);
    }

    /**
     * mutate the given highlighter builder so the returned one is different in one aspect
     */
    private static HighlightBuilder mutate(HighlightBuilder original) throws IOException {
        HighlightBuilder mutation = serializedCopy(original);
        if (randomBoolean()) {
            mutateCommonOptions(mutation);
        } else {
            switch (randomIntBetween(0, 2)) {
                // change settings that only exists on top level
                case 0:
                    mutation.useExplicitFieldOrder(!original.useExplicitFieldOrder()); break;
                case 1:
                    mutation.encoder(original.encoder() + randomAlphaOfLength(2)); break;
                case 2:
                    if (randomBoolean()) {
                        // add another field
                        mutation.field(new Field(randomAlphaOfLength(10)));
                    } else {
                        // change existing fields
                        List<Field> originalFields = original.fields();
                        Field fieldToChange = originalFields.get(randomInt(originalFields.size() - 1));
                        if (randomBoolean()) {
                            fieldToChange.fragmentOffset(randomIntBetween(101, 200));
                        } else {
                            fieldToChange.matchedFields(randomStringArray(5, 10));
                        }
                    }
                    break;
            }
        }
        return mutation;
    }

    private static HighlightBuilder serializedCopy(HighlightBuilder original) throws IOException {
        return ESTestCase.copyWriteable(original, namedWriteableRegistry, HighlightBuilder::new);
    }

    @Override
    protected NamedXContentRegistry xContentRegistry() {
        return xContentRegistry;
    }
}
