/*
 * 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.cassandra.distributed.test.cql3;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.NavigableSet;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;

import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import accord.utils.Gen;
import accord.utils.Gens;
import accord.utils.Property;
import accord.utils.RandomSource;

import org.apache.cassandra.cql3.KnownIssue;
import org.apache.cassandra.cql3.ast.Bind;
import org.apache.cassandra.cql3.ast.Conditional.Where.Inequality;
import org.apache.cassandra.cql3.ast.CreateIndexDDL;
import org.apache.cassandra.cql3.ast.FunctionCall;
import org.apache.cassandra.cql3.ast.Mutation;
import org.apache.cassandra.cql3.ast.ReferenceExpression;
import org.apache.cassandra.cql3.ast.Select;
import org.apache.cassandra.cql3.ast.Symbol;
import org.apache.cassandra.cql3.ast.TableReference;
import org.apache.cassandra.cql3.ast.Value;
import org.apache.cassandra.db.Clustering;
import org.apache.cassandra.db.marshal.AbstractType;
import org.apache.cassandra.db.marshal.InetAddressType;
import org.apache.cassandra.db.marshal.UTF8Type;
import org.apache.cassandra.dht.Murmur3Partitioner;
import org.apache.cassandra.distributed.Cluster;
import org.apache.cassandra.distributed.test.sai.SAIUtil;
import org.apache.cassandra.harry.model.BytesPartitionState;
import org.apache.cassandra.schema.ColumnMetadata;
import org.apache.cassandra.schema.TableMetadata;
import org.apache.cassandra.utils.ASTGenerators;
import org.apache.cassandra.utils.AbstractTypeGenerators;
import org.apache.cassandra.utils.AbstractTypeGenerators.TypeGenBuilder;
import org.apache.cassandra.utils.AbstractTypeGenerators.TypeKind;
import org.apache.cassandra.utils.ByteBufferUtil;
import org.apache.cassandra.utils.CassandraGenerators.TableMetadataBuilder;
import org.apache.cassandra.utils.Generators;
import org.apache.cassandra.utils.ImmutableUniqueList;
import org.apache.cassandra.utils.LoggingCommand;

import static accord.utils.Property.commands;
import static accord.utils.Property.stateful;
import static org.apache.cassandra.utils.AbstractTypeGenerators.getTypeSupport;
import static org.apache.cassandra.utils.Generators.toGen;

public class SingleNodeTableWalkTest extends StatefulASTBase
{
    private static final Gen<Gen<Boolean>> BOOLEAN_DISTRIBUTION = Gens.bools().mixedDistribution();
    //TODO (coverage): COMPOSITE, DYNAMIC_COMPOSITE
    private static final Gen<Gen<TypeKind>> TYPE_KIND_DISTRIBUTION = Gens.mixedDistribution(TypeKind.PRIMITIVE,
                                                                                            TypeKind.SET, TypeKind.LIST, TypeKind.MAP,
                                                                                            TypeKind.TUPLE, TypeKind.UDT,
                                                                                            TypeKind.VECTOR
    );
    private static final Gen<Gen<AbstractType<?>>> PRIMITIVE_DISTRIBUTION = Gens.mixedDistribution(AbstractTypeGenerators.knownPrimitiveTypes()
                                                                                                                         .stream()
                                                                                                                         .filter(t -> !AbstractTypeGenerators.isUnsafeEquality(t))
                                                                                                                         .collect(Collectors.toList()));
    private static final Logger logger = LoggerFactory.getLogger(SingleNodeTableWalkTest.class);

    protected static boolean READ_AFTER_WRITE = false;

    public SingleNodeTableWalkTest()
    {
    }

    protected void preCheck(Cluster cluster, Property.StatefulBuilder builder)
    {
        // if a failing seed is detected, populate here
        // Example: builder.withSeed(42L);
        // CQL operations may have opertors such as +, -, and / (example 4 + 4), to "apply" them to get a constant value
        // CQL_DEBUG_APPLY_OPERATOR = true;
        // When mutations look to be lost as seen by more complex SELECTs, it can be useful to just SELECT the partition/row right after to write to see if it was safe at the time.
        // READ_AFTER_WRITE = true;
    }

    protected TypeGenBuilder supportedTypes(RandomSource rs)
    {
        return AbstractTypeGenerators.builder()
                                     .withTypeKinds(Generators.fromGen(TYPE_KIND_DISTRIBUTION.next(rs)))
                                     .withPrimitives(Generators.fromGen(PRIMITIVE_DISTRIBUTION.next(rs)))
                                     .withUserTypeFields(AbstractTypeGenerators.UserTypeFieldsGen.simpleNames())
                                     .withMaxDepth(1);
    }

    protected TypeGenBuilder supportedPrimaryColumnTypes(RandomSource rs)
    {
        return AbstractTypeGenerators.builder()
                                     .withTypeKinds(TypeKind.PRIMITIVE)
                                     .withPrimitives(Generators.fromGen(PRIMITIVE_DISTRIBUTION.next(rs)));
    }

    protected List<CreateIndexDDL.Indexer> supportedIndexers()
    {
        // since legacy is async it's not clear how the test can wait for the background write to complete...
        return Collections.singletonList(CreateIndexDDL.SAI);
    }

    public Property.Command<State, Void, ?> selectExisting(RandomSource rs, State state)
    {
        NavigableSet<BytesPartitionState.Ref> keys = state.model.partitionKeys();
        BytesPartitionState.Ref ref = rs.pickOrderedSet(keys);
        Clustering<ByteBuffer> key = ref.key;

        Select.Builder builder = Select.builder().table(state.metadata);
        ImmutableUniqueList<Symbol> pks = state.model.factory.partitionColumns;
        ImmutableUniqueList<Symbol> cks = state.model.factory.clusteringColumns;
        for (Symbol pk : pks)
            builder.value(pk, key.bufferAt(pks.indexOf(pk)));

        boolean wholePartition = cks.isEmpty() || rs.nextBoolean();
        if (!wholePartition)
        {
            // find a row to select
            BytesPartitionState partition = state.model.get(ref);
            if (partition.isEmpty())
            {
                wholePartition = true;
            }
            else
            {
                NavigableSet<Clustering<ByteBuffer>> clusteringKeys = partition.clusteringKeys();
                Clustering<ByteBuffer> clusteringKey = rs.pickOrderedSet(clusteringKeys);
                for (Symbol ck : cks)
                    builder.value(ck, clusteringKey.bufferAt(cks.indexOf(ck)));
            }
        }
        Select select = builder.build();
        return state.command(rs, select, (wholePartition ? "By Partition Key" : "By Primary Key"));
    }

    public Property.Command<State, Void, ?> selectToken(RandomSource rs, State state)
    {
        NavigableSet<BytesPartitionState.Ref> keys = state.model.partitionKeys();
        BytesPartitionState.Ref ref = rs.pickOrderedSet(keys);

        Select.Builder builder = Select.builder().table(state.metadata);
        builder.where(FunctionCall.tokenByColumns(state.model.factory.partitionColumns),
                      Inequality.EQUAL,
                      token(state, ref));

        Select select = builder.build();
        return state.command(rs, select, "by token");
    }

    public Property.Command<State, Void, ?> selectTokenRange(RandomSource rs, State state)
    {
        NavigableSet<BytesPartitionState.Ref> keys = state.model.partitionKeys();
        BytesPartitionState.Ref start, end;
        switch (keys.size())
        {
            case 1:
                start = end = Iterables.get(keys, 0);
                break;
            case 2:
                start = Iterables.get(keys, 0);
                end = Iterables.get(keys, 1);
                break;
            case 0:
                throw new IllegalArgumentException("Unable to select token ranges when no partitions exist");
            default:
            {
                int si = rs.nextInt(0, keys.size() - 1);
                int ei = rs.nextInt(si + 1, keys.size());
                start = Iterables.get(keys, si);
                end = Iterables.get(keys, ei);
            }
            break;
        }
        Select.Builder builder = Select.builder().table(state.metadata);
        FunctionCall pkToken = FunctionCall.tokenByColumns(state.model.factory.partitionColumns);
        boolean startInclusive = rs.nextBoolean();
        boolean endInclusive = rs.nextBoolean();
        if (startInclusive && endInclusive && rs.nextBoolean())
        {
            // between
            builder.between(pkToken, token(state, start), token(state, end));
        }
        else
        {
            builder.where(pkToken,
                          startInclusive ? Inequality.GREATER_THAN_EQ : Inequality.GREATER_THAN,
                          token(state, start));
            builder.where(pkToken,
                          endInclusive ? Inequality.LESS_THAN_EQ : Inequality.LESS_THAN,
                          token(state, end));
        }
        Select select = builder.build();
        return state.command(rs, select, "by token range");
    }

    public Property.Command<State, Void, ?> partitionRestrictedQuery(RandomSource rs, State state)
    {
        //TODO (now): remove duplicate logic
        NavigableSet<BytesPartitionState.Ref> keys = state.model.partitionKeys();
        BytesPartitionState.Ref ref = rs.pickOrderedSet(keys);
        Clustering<ByteBuffer> key = ref.key;

        Select.Builder builder = Select.builder().table(state.metadata);
        ImmutableUniqueList<Symbol> pks = state.model.factory.partitionColumns;
        for (Symbol pk : pks)
            builder.value(pk, key.bufferAt(pks.indexOf(pk)));


        List<Symbol> searchableColumns = state.searchableNonPartitionColumns;
        Symbol symbol = rs.pick(searchableColumns);

        TreeMap<ByteBuffer, List<BytesPartitionState.PrimaryKey>> universe = state.model.index(ref, symbol);
        // we need to index 'null' so LT works, but we can not directly query it... so filter out when selecting values
        NavigableSet<ByteBuffer> allowed = Sets.filter(universe.navigableKeySet(), b -> !ByteBufferUtil.EMPTY_BYTE_BUFFER.equals(b));
        if (allowed.isEmpty())
            return Property.ignoreCommand();
        ByteBuffer value = rs.pickOrderedSet(allowed);

        EnumSet<CreateIndexDDL.QueryType> supported = !state.indexes.containsKey(symbol)
                                                      ? EnumSet.noneOf(CreateIndexDDL.QueryType.class)
                                                      : state.indexes.get(symbol).supportedQueries();
        if (supported.isEmpty() || !supported.contains(CreateIndexDDL.QueryType.Range))
            builder.allowFiltering();

        // there are known SAI bugs, so need to avoid them to stay stable...
        if (state.indexes.containsKey(symbol) && state.indexes.get(symbol).indexDDL.indexer == CreateIndexDDL.SAI)
        {
            if (symbol.type() == InetAddressType.instance
                && IGNORED_ISSUES.contains(KnownIssue.SAI_INET_MIXED))
                return eqSearch(rs, state, symbol, value, builder);
        }

        if (rs.nextBoolean())
            return simpleRangeSearch(rs, state, symbol, value, builder);
        //TODO (coverage): define search that has a upper and lower bound: a > and a < | a beteeen ? and ?
        return eqSearch(rs, state, symbol, value, builder);
    }

    public Property.Command<State, Void, ?> nonPartitionQuery(RandomSource rs, State state)
    {
        Symbol symbol = rs.pick(state.searchableColumns);
        TreeMap<ByteBuffer, List<BytesPartitionState.PrimaryKey>> universe = state.model.index(symbol);
        // we need to index 'null' so LT works, but we can not directly query it... so filter out when selecting values
        NavigableSet<ByteBuffer> allowed = Sets.filter(universe.navigableKeySet(), b -> !ByteBufferUtil.EMPTY_BYTE_BUFFER.equals(b));
        if (allowed.isEmpty())
            return Property.ignoreCommand();
        ByteBuffer value = rs.pickOrderedSet(allowed);
        Select.Builder builder = Select.builder().table(state.metadata);

        EnumSet<CreateIndexDDL.QueryType> supported = !state.indexes.containsKey(symbol) ? EnumSet.noneOf(CreateIndexDDL.QueryType.class) : state.indexes.get(symbol).supportedQueries();
        if (supported.isEmpty() || !supported.contains(CreateIndexDDL.QueryType.Range))
            builder.allowFiltering();

        // there are known SAI bugs, so need to avoid them to stay stable...
        if (state.indexes.containsKey(symbol) && state.indexes.get(symbol).indexDDL.indexer == CreateIndexDDL.SAI)
        {
            if (symbol.type() == InetAddressType.instance
                && IGNORED_ISSUES.contains(KnownIssue.SAI_INET_MIXED))
                return eqSearch(rs, state, symbol, value, builder);
        }

        if (rs.nextBoolean())
            return simpleRangeSearch(rs, state, symbol, value, builder);
        //TODO (coverage): define search that has a upper and lower bound: a > and a < | a beteeen ? and ?
        return eqSearch(rs, state, symbol, value, builder);
    }

    public Property.Command<State, Void, ?> multiColumnQuery(RandomSource rs, State state)
    {
        List<Symbol> allowedColumns = state.multiColumnQueryColumns();

        if (allowedColumns.size() <= 1)
            throw new IllegalArgumentException("Unable to do multiple column query when there is only a single column");

        int numColumns = rs.nextInt(1, allowedColumns.size()) + 1;

        List<Symbol> cols = Gens.lists(Gens.pick(allowedColumns)).unique().ofSize(numColumns).next(rs);

        Select.Builder builder = Select.builder().table(state.metadata).allowFiltering();

        for (Symbol symbol : cols)
        {
            TreeMap<ByteBuffer, List<BytesPartitionState.PrimaryKey>> universe = state.model.index(symbol);
            NavigableSet<ByteBuffer> allowed = Sets.filter(universe.navigableKeySet(), b -> !ByteBufferUtil.EMPTY_BYTE_BUFFER.equals(b));
            //TODO (now): support
            if (allowed.isEmpty())
                return Property.ignoreCommand();
            ByteBuffer value = rs.pickOrderedSet(allowed);
            builder.value(symbol, value);
        }

        Select select = builder.build();
        String annotate = cols.stream().map(symbol -> {
            var indexed = state.indexes.get(symbol);
            return symbol.detailedName() + (indexed == null ? "" : " (indexed with " + indexed.indexDDL.indexer.name() + ')');
        }).collect(Collectors.joining(", "));
        return state.command(rs, select, annotate);
    }

    private Property.Command<State, Void, ?> simpleRangeSearch(RandomSource rs, State state, Symbol symbol, ByteBuffer value, Select.Builder builder)
    {
        // do a simple search, like > or <
        Inequality kind = state.rangeInequalityGen.next(rs);
        builder.where(symbol, kind, value);
        Select select = builder.build();
        var indexed = state.indexes.get(symbol);
        return state.command(rs, select, symbol.detailedName() + (indexed == null ? "" : ", indexed with " + indexed.indexDDL.indexer.name()));
    }

    private Property.Command<State, Void, ?> eqSearch(RandomSource rs, State state, Symbol symbol, ByteBuffer value, Select.Builder builder)
    {
        builder.value(symbol, value);

        Select select = builder.build();
        var indexed = state.indexes.get(symbol);
        return state.command(rs, select, symbol.detailedName() + (indexed == null ? "" : ", indexed with " + indexed.indexDDL.indexer.name()));
    }

    public Property.Command<State, Void, ?> clusteringBetweenQuery(RandomSource rs, State state)
    {
        ImmutableUniqueList<Symbol> cks = state.model.factory.clusteringColumns;
        if (cks.isEmpty())
            return Property.ignoreCommand();

        // Select a single partition
        BytesPartitionState.Ref ref = rs.pickOrderedSet(state.model.partitionKeys());
        BytesPartitionState partition = state.model.get(ref);

        NavigableSet<Clustering<ByteBuffer>> clusteringKeys = partition.clusteringKeys();

        // Use first clustering column for BETWEEN
        Symbol ckSymbol = cks.get(0);

        ByteBuffer low, high;
        boolean useRealValues = clusteringKeys.size() >= 2 && rs.nextBoolean();
        if (useRealValues)
        {
            // low and high can match, have high < low, and low < high... this is all expected
            // between where low > high should return nothing
            // between where low == high should be the same as a ck=? query
            low = rs.pickOrderedSet(clusteringKeys).bufferAt(0);
            high = rs.pickOrderedSet(clusteringKeys).bufferAt(0);
        }
        else
        {
            // Generate random values
            Gen<ByteBuffer> bytesGen = toGen(getTypeSupport(ckSymbol.type()).bytesGen());
            low = bytesGen.next(rs);
            high = bytesGen.next(rs);
        }

        Select.Builder builder = Select.builder().table(state.metadata);

        // Add partition key restrictions
        ImmutableUniqueList<Symbol> pks = state.model.factory.partitionColumns;
        Clustering<ByteBuffer> pkKey = ref.key;
        for (Symbol pk : pks)
            builder.value(pk, pkKey.bufferAt(pks.indexOf(pk)));


        String annotation;
        ASTGenerators.RangeType rangeType = rs.pick(ASTGenerators.RangeType.BOUND, ASTGenerators.RangeType.BETWEEN);

        ASTGenerators.applyRangeCondition(builder, rangeType, ckSymbol,
                                          state.greaterThanGen.next(rs),
                                          state.lessThanGen.next(rs),
                                          state.value(rs, low, ckSymbol.type()),
                                          state.value(rs, high, ckSymbol.type()));

        annotation = "clustering " + rangeType.name();

        if (rs.nextBoolean())
            builder.orderByColumn(ckSymbol, rs.pick(Select.OrderBy.Ordering.values()));

        // Check if clustering column is indexed
        var indexed = state.indexes.get(ckSymbol);
        if (indexed != null)
            annotation += " (indexed with " + indexed.indexDDL.indexer.name() + ')';

        // Check if column is reversed (DESC)
        if (ckSymbol.reversed)
            annotation += " [DESC]";

        if (!useRealValues)
            annotation += " [random values]";

        Select select = builder.build();
        return state.command(rs, select, annotation);
    }

    protected State createState(RandomSource rs, Cluster cluster)
    {
        return new State(rs, cluster);
    }

    protected Cluster createCluster() throws IOException
    {
        return createCluster(1);
    }

    @Test
    public void test() throws IOException
    {
        try (Cluster cluster = createCluster())
        {
            Property.StatefulBuilder statefulBuilder = stateful().withExamples(10).withSteps(400);
            preCheck(cluster, statefulBuilder);
            statefulBuilder.check(commands(() -> rs -> createState(rs, cluster))
                                  .add(StatefulASTBase::insert)
                                  .add(StatefulASTBase::fullTableScan)
                                  .addIf(State::allowUsingTimestamp, StatefulASTBase::validateUsingTimestamp)
                                  .addIf(State::hasPartitions, this::selectExisting)
                                  .addAllIf(State::supportTokens,
                                            this::selectToken,
                                            this::selectTokenRange,
                                            StatefulASTBase::selectMinTokenRange)
                                  .addIf(State::hasEnoughMemtable, StatefulASTBase::flushTable)
                                  .addIf(State::hasEnoughSSTables, StatefulASTBase::compactTable)
                                  .addAllIf(BaseState::allowRepair,
                                            StatefulASTBase::incrementalRepair,
                                            StatefulASTBase::previewRepair)
                                  .addIf(State::allowNonPartitionQuery, this::nonPartitionQuery)
                                  .addIf(State::allowNonPartitionMultiColumnQuery, this::multiColumnQuery)
                                  .addIf(State::allowPartitionQuery, this::partitionRestrictedQuery)
                                  .addIf(State::allowClusteringBetweenQuery, this::clusteringBetweenQuery)
                                  .destroyState(State::close)
                                  .commandsTransformer(LoggingCommand.factory())
                                  .onSuccess(onSuccess(logger))
                                  .build());
        }
    }

    protected TableMetadata defineTable(RandomSource rs, String ks)
    {
        //TODO (correctness): the id isn't correct... this is what we use to create the table, so would miss the actual ID
        // Defaults may also be incorrect, but given this is the same version it "shouldn't"
        //TODO (coverage): partition is defined at the cluster level, so have to hard code in this model as the table is changed rather than cluster being recreated... this limits coverage
        return toGen(new TableMetadataBuilder()
                     .withTableKinds(TableMetadata.Kind.REGULAR)
                     .withParams(b -> b.withKnownMemtables()
                                       .withCaching()
                                       .withCompaction()
                                       .withCompression())
                     .withKeyspaceName(ks).withTableName("tbl")
                     .withSimpleColumnNames()
                     .withDefaultTypeGen(supportedTypes(rs))
                     .withPrimaryColumnTypeGen(supportedPrimaryColumnTypes(rs))
                     .withPartitioner(Murmur3Partitioner.instance)
                     .build())
               .next(rs);
    }

    private List<CreateIndexDDL.Indexer> columnSupportsIndexing(TableMetadata metadata, ColumnMetadata col)
    {
        return supportedIndexers().stream()
                                  .filter(i -> i.supported(metadata, col))
                                  .collect(Collectors.toList());
    }

    private static FunctionCall token(State state, BytesPartitionState.Ref ref)
    {
        Preconditions.checkNotNull(ref.key);
        List<Value> values = new ArrayList<>(ref.key.size());
        for (int i = 0; i < ref.key.size(); i++)
        {
            ByteBuffer bb = ref.key.bufferAt(i);
            Symbol type = state.model.factory.partitionColumns.get(i);
            values.add(new Bind(bb, type.type()));
        }
        return FunctionCall.tokenByValue(values);
    }

    public class State extends CommonState
    {
        protected final LinkedHashMap<Symbol, IndexedColumn> indexes;
        private final Gen<Mutation> mutationGen;
        private final List<Symbol> searchableNonPartitionColumns;
        private final List<Symbol> searchableColumns;
        private final List<Symbol> nonPkIndexedColumns;

        public State(RandomSource rs, Cluster cluster)
        {
            super(rs, cluster, defineTable(rs, nextKeyspace()));

            this.indexes = createIndexes(rs, metadata);

            cluster.forEach(i -> i.nodetoolResult("disableautocompaction", metadata.keyspace, this.metadata.name).asserts().success());

            ASTGenerators.MutationGenBuilder mutationGenBuilder = new ASTGenerators.MutationGenBuilder(metadata)
                                                                  .withTxnSafe()
                                                                  .withColumnExpressions(e -> e.withOperators(Generators.fromGen(BOOLEAN_DISTRIBUTION.next(rs))))
                                                                  .withListElementAccessForUpdateSet(allowListElementAccessForUpdateSet())
                                                                  .withIgnoreIssues(IGNORED_ISSUES);

            // Run the test with and without bound partitions
            // When using fixed partitions, each mutation will be for a single partition and will use pk=? syntax
            // When using unbounded partitions then IN clause is used on partition keys, leading to mutations touching multiple partitions
            if (rs.nextBoolean())
            {
                List<LinkedHashMap<Symbol, Object>> uniquePartitions;
                {
                    int unique = rs.nextInt(1, 10);
                    List<Symbol> columns = model.factory.partitionColumns;
                    List<Gen<?>> gens = new ArrayList<>(columns.size());
                    for (int i = 0; i < columns.size(); i++)
                        gens.add(toGen(getTypeSupport(columns.get(i).type()).valueGen));
                    uniquePartitions = Gens.lists(r2 -> {
                        LinkedHashMap<Symbol, Object> vs = new LinkedHashMap<>();
                        for (int i = 0; i < columns.size(); i++)
                            vs.put(columns.get(i), gens.get(i).next(r2));
                        return vs;
                    }).uniqueBestEffort().ofSize(unique).next(rs);
                }
                mutationGenBuilder.withPartitions(Generators.fromGen(Gens.mixedDistribution(uniquePartitions).next(rs)));
            }

            if (IGNORED_ISSUES.contains(KnownIssue.SAI_EMPTY_TYPE))
            {
                model.factory.regularAndStaticColumns.stream()
                                                     // exclude SAI indexed columns
                                                     .filter(s -> !indexes.containsKey(s) || indexes.get(s).indexDDL.indexer != CreateIndexDDL.SAI)
                                                     .forEach(mutationGenBuilder::allowEmpty);
            }
            else
            {
                model.factory.regularAndStaticColumns.forEach(mutationGenBuilder::allowEmpty);
            }
            model.factory.regularAndStaticColumns.forEach(mutationGenBuilder::allowNull);
            this.mutationGen = toMutationGen(mutationGenBuilder);

            var nonPartitionColumns = ImmutableList.<Symbol>builder()
                                                   .addAll(model.factory.clusteringColumns)
                                                   .addAll(model.factory.staticColumns)
                                                   .addAll(model.factory.regularColumns)
                                                   .build();
            searchableNonPartitionColumns = nonPartitionColumns.stream()
                                                               .filter(this::isSearchable)
                                                               .collect(Collectors.toList());
            nonPkIndexedColumns = nonPartitionColumns.stream()
                                                     .filter(indexes::containsKey)
                                                     .collect(Collectors.toList());

            searchableColumns = (metadata.partitionKeyColumns().size() > 1 ? model.factory.selectionOrder : nonPartitionColumns)
                                .stream()
                                .filter(this::isSearchable)
                                .collect(Collectors.toList());
        }

        @Override
        protected boolean readAfterWrite()
        {
            return READ_AFTER_WRITE;
        }

        protected Gen<Mutation> toMutationGen(ASTGenerators.MutationGenBuilder mutationGenBuilder)
        {
            return toGen(mutationGenBuilder.build());
        }

        private boolean isSearchable(Symbol symbol)
        {
            // See org.apache.cassandra.cql3.Operator.validateFor
            // multi cell collections can only be searched if you search their elements, not the collection as a whole
            //TODO (coverage): can you query for UDT fields?  its a single cell so you "should"?
            return !(symbol.type().isMultiCell() && (symbol.type().isCollection() || symbol.type().isUDT()));
        }

        @Override
        protected Gen<Mutation> mutationGen()
        {
            return mutationGen;
        }

        private LinkedHashMap<Symbol, IndexedColumn> createIndexes(RandomSource rs, TableMetadata metadata)
        {
            LinkedHashMap<Symbol, IndexedColumn> indexed = new LinkedHashMap<>();
            // for some test runs, avoid using indexes
            if (rs.nextBoolean())
                return indexed;
            for (ColumnMetadata col : metadata.columnsInFixedOrder())
            {
                Symbol symbol = Symbol.from(col);
                AbstractType<?> type = symbol.type();

                if (col.name.toString().length() >= 48
                    && IGNORED_ISSUES.contains(KnownIssue.CUSTOM_INDEX_MAX_COLUMN_48))
                    continue;

                if (type.isCollection() && !type.isFrozenCollection()) continue; //TODO (coverage): include non-frozen collections;  the index part works fine, its the select that fails... basic equality isn't allowed for map type... so how do you query?
                List<CreateIndexDDL.Indexer> allowed = columnSupportsIndexing(metadata, col);
                if (allowed.isEmpty()) continue;
                CreateIndexDDL.Indexer indexer = rs.pick(allowed);
                ReferenceExpression colExpression = Symbol.from(col);
                if (type.isFrozenCollection())
                    colExpression = new CreateIndexDDL.CollectionReference(CreateIndexDDL.CollectionReference.Kind.FULL, colExpression);

                String name = "tbl_" + col.name;
                CreateIndexDDL ddl = new CreateIndexDDL(rs.pick(CreateIndexDDL.Version.values()),
                                                        indexer,
                                                        Optional.of(new Symbol(name, UTF8Type.instance)),
                                                        TableReference.from(metadata),
                                                        Collections.singletonList(colExpression),
                                                        Collections.emptyMap());
                String stmt = ddl.toCQL();
                logger.info(stmt);
                cluster.schemaChange(stmt);

                //noinspection OptionalGetWithoutIsPresent
                SAIUtil.waitForIndexQueryable(cluster, metadata.keyspace, ddl.name.get().name());

                indexed.put(symbol, new IndexedColumn(symbol, ddl));
            }
            return indexed;
        }

        public boolean supportTokens()
        {
            return hasPartitions();
        }

        public boolean allowNonPartitionQuery()
        {
            return !model.isEmpty() && !searchableColumns.isEmpty();
        }

        public boolean allowNonPartitionMultiColumnQuery()
        {
            return allowNonPartitionQuery() && multiColumnQueryColumns().size() > 1;
        }

        private List<Symbol> multiColumnQueryColumns()
        {
            List<Symbol> allowedColumns = searchableColumns;
            if (hasMultiNodeMultiColumnAllowFilteringWithLocalWritesIssue())
                allowedColumns = nonPkIndexedColumns;
            if (IGNORED_ISSUES.contains(KnownIssue.SAI_AND_VECTOR_COLUMNS) && !indexes.isEmpty())
                allowedColumns = allowedColumns.stream().filter(s -> !s.type().isVector()).collect(Collectors.toList());
            return allowedColumns;
        }

        private boolean hasMultiNodeMultiColumnAllowFilteringWithLocalWritesIssue()
        {
            return isMultiNode() && IGNORED_ISSUES.contains(KnownIssue.AF_MULTI_NODE_MULTI_COLUMN_AND_NODE_LOCAL_WRITES);
        }

        public boolean allowPartitionQuery()
        {
            return !(model.isEmpty() || searchableNonPartitionColumns.isEmpty());
        }

        public boolean allowClusteringBetweenQuery()
        {
            return hasPartitions() && !model.factory.clusteringColumns.isEmpty();
        }

        @Override
        public String toString()
        {
            StringBuilder sb = new StringBuilder();
            sb.append("\nSetup:\n");
            toString(sb);
            indexes.values().forEach(c -> sb.append('\n').append(c.indexDDL.toCQL()).append(';'));
            return sb.toString();
        }
    }

    public static class IndexedColumn
    {
        public final Symbol symbol;
        public final CreateIndexDDL indexDDL;

        public IndexedColumn(Symbol symbol, CreateIndexDDL indexDDL)
        {
            this.symbol = symbol;
            this.indexDDL = indexDDL;
        }

        public EnumSet<CreateIndexDDL.QueryType> supportedQueries()
        {
            return indexDDL.indexer.supportedQueries(symbol.type());
        }
    }
}
